Compare commits

..
Author SHA1 Message Date
rdbtCVSandCodex GPT-6.1 Terra fa0e2b01cd Merge updated item and helper work into mob groundwork
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:22:25 +00:00
rdbtCVSandCodex GPT-6.1 Terra f663c83f98 Merge updated parallel tick helpers into item moves
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:20:28 +00:00
rdbtCVSandCodex GPT-6.1 Terra 4740691c04 Merge origin/main into parallel tick helpers
Co-Authored-By: Codex GPT-6.1 Terra <[email protected]>
2026-11-11 08:18:54 +00:00
rdbt 04a26210e3 Merge pull request #15038 from horizon-rust/fix/main-ci-after-bulk-merge
Fix CEM compilation and scaled mob culling bounds
2026-11-11 02:39:18 +00:00
rdbtCVSandCodex f37bcae92c Scale culling margins with the mob model root
Include the root model scale in the appendage margin so animated elder guardian vertices remain inside the precheck bounds. The existing prepared-model coverage test remains unchanged.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:55:39 +00:00
rdbtCVSandCodex 04b53013c8 Fix CEM own-model lookup shadowing
Keep the own-file map available when selecting the layers covered by a model.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-10 23:45:10 +00:00
rdbt 5070174aef Merge pull request #14707 from masa-masa-0314/docs/friends-overlay-part2-spec
Docs: propose the Friends overlay Part 2 plan
2026-11-10 23:31:27 +00:00
rdbt 5841fdc1e7 Merge pull request #14658 from readerkid/fix/cem-armour-layers
Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
2026-11-10 23:31:23 +00:00
rdbt 2093fb4d0d Merge pull request #14627 from MrCloudy2/fix/piston-cactus-vehicles
A cactus pricks a boat or a minecart a piston pushes
2026-11-10 23:31:19 +00:00
rdbt 4e84f9e7fe Merge pull request #14623 from MrCloudy2/fix/projectile-hits-tnt
A projectile hits primed TNT
2026-11-10 23:31:14 +00:00
rdbt 6d45e61cdc Merge pull request #14621 from MrCloudy2/fix/wind-bursts-all-push
Every wind burst of a tick pushes an entity that ticks after the projectiles
2026-11-10 23:31:10 +00:00
rdbt 960c03a4a1 Merge pull request #14619 from MrCloudy2/fix/projectile-fluid-current
A flying projectile is pushed by the water and lava it touches
2026-11-10 23:31:06 +00:00
rdbt 7618cdca8e Merge pull request #14533 from MrCloudy2/fix/geyser-through-scaffolding
A geyser goes through scaffolding seen from below
2026-11-10 23:30:59 +00:00
rdbt 31c02b8f59 Merge pull request #14513 from Detimes/gap-line-of-sight
Native hooks: line of sight between entities and a clear-sight block ray
2026-11-10 23:30:55 +00:00
rdbt 67c6dfbca9 Merge pull request #14512 from Detimes/gap-riding-getters
Native hooks: read the vehicle an entity rides and its passengers
2026-11-10 23:30:51 +00:00
rdbt 7ef8abd367 Merge pull request #14510 from Detimes/gap-server-props-read
Native hooks: read the dedicated server's online mode, port and proxy forwarding
2026-11-10 23:30:47 +00:00
rdbt 63f20e8f30 Merge pull request #14498 from MrCloudy2/fix/geyser-launches-projectiles
A geyser launches projectiles, boats and minecarts
2026-11-10 23:30:42 +00:00
rdbt f25fdf5828 Merge pull request #14490 from MSedgeMC/fix/wither-side-head-los
A wither's side head does not pick targets through a wall
2026-11-10 23:30:38 +00:00
rdbt 87ef5ed8d9 Merge pull request #14484 from MrCloudy2/fix/wind-charge-hurts-vehicles
A wind charge's hit damages a boat or a minecart
2026-11-10 23:30:33 +00:00
rdbt 31e1d6aedf Merge pull request #14462 from ilguerro/fix/spilled-items-pop-up
Items spilled from a destroyed shulker box or bundle pop upward again
2026-11-10 23:30:29 +00:00
rdbt cc4067a168 Merge pull request #14441 from voduff/fix/12038-cube-body-item-replace
A sulfur cube's body takes a swallowable block through /item replace
2026-11-10 23:30:25 +00:00
rdbt f7c9520898 Merge pull request #14437 from J5Casey/fix/synced-recipe-display-numbers
Recipe book numbers a server's synced recipes as the server does
2026-11-10 23:30:21 +00:00
rdbt 417728114c Merge pull request #14430 from horizon-rust/split/crate-lang
Crates ship their own lang files: horizon_platform::lang (layer plan M5)
2026-11-10 23:30:17 +00:00
rdbt 1f2fd92867 Merge pull request #14402 from vnsmoreira/packs/polytone-physics-spawn
Polytone: a particle with physics born inside a block never joins
2026-11-10 23:30:13 +00:00
rdbt b955f83e5b Merge pull request #14401 from DUzzL/fix/14382-jmath-pow-nan
Fix jmath::pow NaN special cases to match Java Math.pow
2026-11-10 23:30:09 +00:00
rdbt 916dd0cebb Merge pull request #14332 from yahya-nusseibeh/fix/registry-rusqlite-0.41
Mod registry: bundle SQLite 3.54 through rusqlite 0.41
2026-11-10 23:30:04 +00:00
rdbt b4977c3700 Merge pull request #14277 from Detimes/gap-mob-death-message
Native hooks: living_death_message (mob death message)
2026-11-10 23:30:00 +00:00
rdbt 8cbfc508ae Merge pull request #14271 from Detimes/gap-entity-lookup
Native hooks: read-only entity lookup by UUID and entity data in any level
2026-11-10 23:29:55 +00:00
rdbt 1843b792ff Merge pull request #14270 from Mik00000/perf/lighting-propagation-batch
perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
2026-11-10 23:29:51 +00:00
rdbt 8b6513f833 Merge pull request #14021 from MSedgeMC/fix/weapon-damage-component
Weapon wear comes from a stack's weapon component
2026-11-10 23:29:47 +00:00
rdbt 3895a99900 Merge pull request #13988 from MSedgeMC/fix/banner-layer-codec
A banner with an invalid layer loads no patterns
2026-11-10 23:29:42 +00:00
rdbt 70f3965c0a Merge pull request #13761 from TheMrZZ/fix/h194-item-count-predicate-2
Match item count equality against the actual stack
2026-11-10 23:29:38 +00:00
rdbt e51f313b02 Merge pull request #11559 from voidsipp/fix/11329-villager-reputation-events
Use cached villager murder witnesses
2026-11-10 23:29:34 +00:00
rdbt 339bda5ed2 Merge pull request #11536 from voidsipp/fix/11336-potion-contents
Expire custom potion contents on tipped arrows
2026-11-10 23:29:29 +00:00
rdbt 0cc2963ccf Merge pull request #11484 from voidsipp/fix/11318-portal-cooldowns
Keep entity-specific Nether portal cooldowns
2026-11-10 23:29:25 +00:00
rdbt 1b27a37702 Merge pull request #15009 from DUzzL/fix/14956-powder-snow-order
Mobs: tick freezing after the move and block effects
2026-11-10 23:26:54 +00:00
rdbt d4d833f199 Merge pull request #14945 from CocofireHD/fix/14857-zombie-villager-profession
A zombie villager's profession is the constructor's draw from its own random (#14857)
2026-11-10 23:26:50 +00:00
rdbt 1101314498 Merge pull request #14775 from NongTham/fix/14664-bed-explosion-flying-kick
test: allow-flight in bed_explosion parity harness (fixes #14664)
2026-11-10 23:26:46 +00:00
rdbt 68b4c07179 Merge pull request #14549 from vivescene/docs/agents-review-when-asked
AGENTS.md: review pull requests only when asked, and submit a decision
2026-11-10 23:26:42 +00:00
rdbt 395789e107 Merge pull request #14509 from Detimes/gap-map-colors-read
Native hooks: read a map's colours (native_map_colors)
2026-11-10 23:26:37 +00:00
rdbt 9223dc0e30 Merge pull request #14446 from Mayonesu0/up-12937-dispenser-equip
Dispensers equip server players and respect armor stand slot locks
2026-11-10 23:26:33 +00:00
rdbt d9fe05af0d Merge pull request #14439 from horizon-rust/split/friends-title-icon
Title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
2026-11-10 23:26:29 +00:00
rdbt f02798aed5 Merge pull request #14130 from MSedgeMC/port_map/destroy-progress-and-daylight
port map: the destroy-progress stage and the daylight ticker rows are ports
2026-11-10 23:26:25 +00:00
rdbt 900933aae2 Merge pull request #13762 from TheMrZZ/fix/h211-shulker-drop-lock-2
Preserve external locks on destroyed shulker boxes
2026-11-10 23:26:21 +00:00
rdbt 2ef31a7618 Merge pull request #15014 from darkliro/perf/linux-hybrid-cores
threads: detect performance cores on Linux hybrid CPUs
2026-11-10 23:26:03 +00:00
rdbt cbf533a8ae Merge pull request #14360 from NikitaMGrimm/frame-data-count
/data on an item frame detaches its old map when only the stack's count changes
2026-11-10 23:25:53 +00:00
rdbt ccc233474d Merge pull request #13753 from glitchsnake/fix/12935-sculk-particle-resume-reload
sculk: resume vibration particle on reload (fixes #12935)
2026-11-10 23:25:25 +00:00
rdbt 1b16aaecbf Merge pull request #15027 from NikitaMGrimm/fix/hold-fullbright-replay
Main compiles again: hold_fullbright's replay check and the firework tests' new_rocket calls
2026-11-10 23:20:47 +00:00
b972efd9fa test: prove item work-ahead collisions, RNG and native fallback
Retain the 307-item corpus and add real boat and border cases, independently expected RNG advances, the environment gate and off/on/off comparison. Reject count, age and pickup-delay mutations independently and retain actual merge invalidation. Exercise 300 nonmerging opaque-component stacks in each native contact mode.

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

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:17:06 +00:00
8a4ca5e7de Merge helper verification into dependent item moves
Preserve helper test commit 41a579d428 and the item phase's original history and authors.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 23:08:41 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 2160d3e4d1 The firework event tests pass new_rocket its random
#14603 gave new_rocket the rocket's own random as a fourth argument; the
tests added by 188817342 still called it with three, so the client's tests
stopped compiling on main.

Refs #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:08:01 +01:00
Nikita Maximilian Grimm 4536ec51ca Merge remote-tracking branch 'origin/main' into fix/hold-fullbright-replay 2026-11-11 00:01:35 +01:00
rdbtCVS 5cc88b517b test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
(cherry picked from commit 41a579d428)
2026-11-10 22:55:55 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 72d1238876 hold_fullbright asks the replay whether it plays
#14335 read Game::studio_replay, which the layer split had moved to
Game::replay before it merged, so main stopped compiling.

Fixes #15025

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:51:58 +01:00
rdbt 214469a4bf Merge pull request #14737 from ilguerro/fix/sitting-cat-blocks-comparator
Silence a chest's comparator while a cat sits on it
2026-11-10 22:51:41 +00:00
rdbt b2bfa61fb1 Merge pull request #14987 from DUzzL/fix/14856-shulker-armor
A shulker keeps its armor attribute once its lid has moved
2026-11-10 22:49:57 +00:00
rdbt 5f4dbb058b Merge pull request #14969 from vermingov/fix/villager-food-level-14967
A villager narrows a loaded FoodLevel to a byte (#14967)
2026-11-10 22:49:50 +00:00
rdbt 292dfe14e1 Merge pull request #14955 from DUzzL/fix/14832-zombie-villager-cure
A zombie villager starts its cure when it loads a ConversionTime
2026-11-10 22:49:45 +00:00
rdbt 506624b951 Merge pull request #14951 from Web-mc1/fix/biome-get-empty
Fix biome table panic when empty
2026-11-10 22:49:39 +00:00
rdbt e7fb465789 Merge pull request #14935 from TheMrZZ/slot-sources/holder-tag-codec
Slot sources: holder and tag codec, and slot-source tags in DataStore
2026-11-10 22:49:33 +00:00
rdbt 31af1ed8ad Merge pull request #14932 from Detimes/perf-replay-excuse
Perf: the replay excuse tests the distance before the entity kind
2026-11-10 22:49:28 +00:00
rdbt af3a8d235b Merge pull request #14928 from MSedgeMC/fix/crafter-recipe-book
port: a crafter's grid counts for the recipe book
2026-11-10 22:49:22 +00:00
rdbt 3c564f0e2b Merge pull request #14910 from oloion/fix/custom-crafting-recipes-book
client: show custom server crafting recipes in recipe book
2026-11-10 22:49:17 +00:00
rdbt 45c4e0d744 Merge pull request #14900 from MSedgeMC/fix/jukebox-block-change
port: a disc coming or going posts block_change
2026-11-10 22:49:12 +00:00
rdbt 802056c402 Merge pull request #14892 from NikitaMGrimm/shader-api-primaries-key
Shader API 0.2: [frame] primaries, and the rule for new names
2026-11-10 22:49:06 +00:00
rdbt fc604a958a Merge pull request #14785 from MSedgeMC/fix/merchant-scroller-cursor
The merchant's trade scroller shows its cursor
2026-11-10 22:49:01 +00:00
rdbt 8f98a65be5 Merge pull request #14776 from NongTham/fix/14715-modifier-key-handling
input: clear modifiers on focus loss and heal alt and super keys (fixes #14715)
2026-11-10 22:48:56 +00:00
rdbt 75dc9db341 Merge pull request #14772 from foudretbr/fix/attack-speed-attribute-modifiers
fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721)
2026-11-10 22:48:51 +00:00
rdbt eb161b9c99 Merge pull request #14745 from MSedgeMC/fix/feed-never-baby
A mob that cannot be a baby does not take the food that grows it
2026-11-10 22:48:44 +00:00
rdbt 7687b2a619 Merge pull request #14742 from foudretbr/fix/modifier-key-debug-shortcuts-focus-loss
fix(input): reset debug keys modifier state on window focus loss (#14715)
2026-11-10 22:48:39 +00:00
rdbt 1d50e158b9 Merge pull request #14723 from ilguerro/fix/firework-silent-explode-stat
Firework rockets: honour Silent, post EXPLODE, count block launches
2026-11-10 22:48:33 +00:00
rdbt 58b5d93f8f Merge pull request #14710 from glitchsnake/fix/14467-squid-noai-motion
mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
2026-11-10 22:48:28 +00:00
rdbt 9951ef958d Merge pull request #14705 from Skr1zzzz/falling-model-cache
Falling blocks: cache each model's vertices instead of rebuilding them every frame
2026-11-10 22:48:22 +00:00
rdbt 8cb5c02d4b Merge pull request #14703 from ilguerro/fix/horses-do-not-climb
Stop horses, llamas, camels and flying monsters climbing vines and ladders
2026-11-10 22:48:17 +00:00
rdbt b75ffd9554 Merge pull request #14699 from NikitaMGrimm/hdr-pq-dither
HDR10 output: the last pass dithers its 10 bits, so a clear sky doesn't band
2026-11-10 22:48:12 +00:00
rdbt c7552a32e5 Merge pull request #14697 from ilguerro/fix/phantoms-glass-roof
Spawn phantoms under a glass roof
2026-11-10 22:48:07 +00:00
rdbt ff03932fb0 Merge pull request #14693 from ilguerro/fix/turtle-frog-lay-events
Post block_place when turtles and frogs lay eggs, and stop bred turtles bursting hearts
2026-11-10 22:48:02 +00:00
rdbt 6a007e8e02 Merge pull request #14691 from ilguerro/fix/campfire-candle-douse
Sneaking keeps the held food off campfires and candles answer pick-block
2026-11-10 22:47:57 +00:00
rdbt a4b71ca8ba Merge pull request #14689 from ilguerro/fix/shulker-teleport-vibration
Post a shulker teleport's game event for sculk sensors
2026-11-10 22:47:52 +00:00
rdbt 1386a7fd1f Merge pull request #14677 from YourPancakes/perf/skip-full-cube-shape-allocation
perf(world): avoid full-cube allocation in collision checks
2026-11-10 22:47:47 +00:00
rdbt 5f029bb260 Merge pull request #14672 from YourPancakes/perf/cache-flat-compute-bind-groups
perf(render): cache flat compute bind groups
2026-11-10 22:47:42 +00:00
rdbt 23a2b65c29 Merge pull request #14667 from vnsmoreira/fix/dispensed-item-random
A dispensed item draws its constructor's motion from its own random
2026-11-10 22:47:37 +00:00
rdbt 62165d135d Merge pull request #14665 from YourPancakes/perf/sky-octagon-half-planes
perf(render): evaluate sky octagon with half-planes
2026-11-10 22:47:31 +00:00
rdbt 2732d737a1 Merge pull request #14653 from Ragbie/tests/pad-parity-actions
pad::parity_tests: the pad_press helper records the press, so four tests pass again
2026-11-10 22:47:25 +00:00
rdbt a1807b93a1 Merge pull request #14629 from Ragbie/tests/tnt-silence-stack
TNT silence tests run on an 8 MiB stack, so they don't overflow on Windows
2026-11-10 22:47:18 +00:00
rdbt 53c0b0694b Merge pull request #14602 from ilguerro/fix/sniffer-dig-vibration-silence
Keep animal_brain.rs at main's line count
2026-11-10 22:47:13 +00:00
rdbt cc3e92cadd Merge pull request #14600 from ilguerro/fix/bred-wolf-voice
Move the bred offspring's draws and their test out of creatures.rs
2026-11-10 22:47:08 +00:00
rdbt 27e0787a23 Merge pull request #14592 from NikitaMGrimm/parity/bucket-mob-from-bucket
A mob from a bucket is not persistent, a sulfur cube keeps its bucket's age, and tadpoles never despawn
2026-11-10 22:47:02 +00:00
rdbt f312c30454 Merge pull request #14585 from foudretbr/fix/cat-default-variant-black
fix(mobs): default cat variant to black when omitted
2026-11-10 22:46:57 +00:00
rdbt a79db77ac1 Merge pull request #14573 from MSedgeMC/ledger/moved-citations
port map: point citations at the modules they moved to
2026-11-10 22:46:52 +00:00
rdbt 5fde1275b5 Merge pull request #14556 from ilguerro/fix/wall-hanging-sign-click-command
Run a waxed wall hanging sign's click command instead of chaining a sign
2026-11-10 22:46:46 +00:00
rdbt 7ebdfbc3ee Merge pull request #14508 from Ragbie/tests/mob-landing-neighbourhood
mob_landing_support_tests load the chunks a pig needs to tick
2026-11-10 22:46:41 +00:00
rdbt 9e4e43a69b Merge pull request #14496 from horizon-rust/fix/service-images-path-crates
Telemetry and mod registry images copy every crate their lockfile builds
2026-11-10 22:46:35 +00:00
rdbt 78f286e657 Merge pull request #14492 from Ragbie/fix/landing-vibrations
Primed TNT and falling blocks make a vibration when they land
2026-11-10 22:46:28 +00:00
rdbt 71e8f7c8cd Merge pull request #14489 from MSedgeMC/fix/wololo-visibility
An invisible blue sheep is not wololoed from five blocks
2026-11-10 22:46:23 +00:00
rdbt 91429d906a Merge pull request #14488 from MSedgeMC/fix/sulfur-tempt-visibility
A sulfur cube's tempt range shrinks for an invisible player
2026-11-10 22:46:17 +00:00
rdbt 846e34c2eb Merge pull request #14486 from vnsmoreira/fix/golden-horse-armor
A horse draws its golden armour
2026-11-10 22:46:10 +00:00
rdbt a084ad2f35 Merge pull request #14468 from Solenopsisbot/fix/high-contrast-pack-hang
Fix startup hang with High Contrast resource pack
2026-11-10 22:46:05 +00:00
rdbt 10588c9b4c Merge pull request #14448 from ilguerro/fix/frame-vibrations
Let sculk hear item frame changes as vanilla does
2026-11-10 22:46:00 +00:00
rdbt 8ad2bee66a Merge pull request #14434 from MSedgeMC/fix/written-book-resolve
port: a written book resolves its selectors and scores when it is opened
2026-11-10 22:45:54 +00:00
rdbt 3e8dbbc2df Merge pull request #14423 from YourPancakes/perf/skip-swim-below-block-reads
perf(mobs): skip redundant swim block reads
2026-11-10 22:45:49 +00:00
rdbt bbe1ec5372 Merge pull request #14415 from YourPancakes/perf/villager-item-sensing-index
perf(mobs): reuse item cells for villager sensing
2026-11-10 22:45:43 +00:00
rdbt 1f6cf55e1c Merge pull request #14413 from MSedgeMC/fix/shulker-neighbor-updates
port: a shulker box lid updates its neighbours as it moves
2026-11-10 22:45:38 +00:00
rdbt 221d303a90 Merge pull request #14409 from Joansitoh/fix-14407-version-picker-scroll
Launcher: the Home version picker scrolls its list
2026-11-10 22:45:32 +00:00
rdbt 83cb8979be Merge pull request #14399 from Cx188/fix/14338-command-hints-scroll
chat: fix touchpad scrolling in command suggestions
2026-11-10 22:45:26 +00:00
rdbt 1f10f8bbb0 Merge pull request #14398 from MSedgeMC/fix/social-scrollbar
The social player list shows its scrollbar
2026-11-10 22:45:22 +00:00
rdbt 67de3115e2 Merge pull request #14396 from MSedgeMC/fix/creative-tab-tooltip
The creative inventory's tabs say their name on hover
2026-11-10 22:45:17 +00:00
rdbt 53aab90fb5 Merge pull request #14393 from vivescene/perf/vehicle-mob-grid
Server tick: boats and minecarts read the mobs near them, not every mob
2026-11-10 22:45:12 +00:00
rdbt 0e120f96d5 Merge pull request #14392 from YourPancakes/perf/reuse-native-sprite-frames
perf(render): reuse native sprite frames when sizes match
2026-11-10 22:45:06 +00:00
rdbt 1f1fa1fc4d Merge pull request #14386 from vnsmoreira/fix/glide-fall-accumulation
A gliding mob caps its fall at 1 unless it dives
2026-11-10 22:44:59 +00:00
rdbt 8ffe3913ea Merge pull request #14379 from martipops/agent/pack-card-pad-target
Pack screen: a pack card's options button is a pad target
2026-11-10 22:44:54 +00:00
rdbt 87fe5531ed Merge pull request #14378 from ilguerro/fix/mount-feeding-eat
Feeding a horse or camel makes a vibration and a Silent one eats quietly
2026-11-10 22:44:50 +00:00
rdbt 255baf27e3 Merge pull request #14367 from DUzzL/fix/14297-hopper-decorated-pot
Let hoppers pull items out of a decorated pot
2026-11-10 22:44:44 +00:00
rdbt a713ed14e5 Merge pull request #14364 from DUzzL/fix/14298-tab-name-translation
Resolve a creative tab name given as a translation key
2026-11-10 22:44:37 +00:00
rdbt 4973e64a25 Merge pull request #14362 from voduff/map-stale-gaps-005
port map: player, villager, witch, wind and wither skull render rows (client-005, client-007)
2026-11-10 22:44:32 +00:00
rdbt 0a42c8f111 Merge pull request #14359 from ilguerro/fix/breed-partner-range
Move the breed goal tests out of goals.rs
2026-11-10 22:44:27 +00:00
rdbt 8c1118a3cf Merge pull request #14357 from voduff/map-stale-gaps-003
port map: boat wobble, skeleton shake, cat poses, block displays are drawn (client-003)
2026-11-10 22:44:22 +00:00
rdbt 6a88a1335d Merge pull request #14354 from voduff/map-stale-gaps-008
port map: block entity crack overlays are drawn (client-008)
2026-11-10 22:44:17 +00:00
rdbt e02af2c3bf Merge pull request #14347 from Cx188/fix/8406-stage-local-reachability
Drop unreachable graphics-stage GLSL helpers
2026-11-10 22:44:11 +00:00
rdbt a31f9f4763 Merge pull request #14344 from MSedgeMC/fix/test-block-analog-state-gate
A test block reads its block state mode for a comparator
2026-11-10 22:44:03 +00:00
rdbt 50486a59b0 Merge pull request #14337 from voduff/map-particle-anvil
port map: the particle limit and anvil rename box rows name their fns
2026-11-10 22:43:58 +00:00
rdbt e6ab43107a Merge pull request #14330 from yahya-nusseibeh/fix/report-claim-before-filing
Telemetry server: claim a bug report before filing its issue
2026-11-10 22:43:53 +00:00
rdbt 912e18ed0a Merge pull request #14329 from yahya-nusseibeh/fix/text-push-in-place
Text: append siblings in place instead of cloning the tree each time
2026-11-10 22:43:47 +00:00
rdbt eef3c52455 Merge pull request #14328 from yahya-nusseibeh/fix/translation-span-budget
Text: bound what one translation's fallback template expands to
2026-11-10 22:43:42 +00:00
rdbt 0e4511ae40 Merge pull request #14326 from ilguerro/fix/breeze-dust-support
Breeze dust crumbles the fence, wall or gate it stands on
2026-11-10 22:43:36 +00:00
rdbt dfa0603931 Merge pull request #14315 from vnsmoreira/packs/polytone-rnoise
Polytone: r.noise is 1.21's PerlinSimplexNoise, seed 0, octave 1
2026-11-10 22:43:31 +00:00
rdbt cf3d748474 Merge pull request #14312 from ilguerro/fix/enderman-persistent-anger
Keep an enderman's anger after it loses its target
2026-11-10 22:43:25 +00:00
rdbt 865f209838 Merge pull request #14308 from MSedgeMC/fix/named-fish-stays
A named fish or axolotl never despawns
2026-11-10 22:43:20 +00:00
rdbt aefc8c5254 Merge pull request #14307 from Akirabane/fix/jmath-log-windows
jmath: Math.log on Windows rounds like HotSpot's x86-64 stub, as sin and cos do
2026-11-10 22:43:15 +00:00
rdbt b1f72f3449 Merge pull request #14304 from NikitaMGrimm/record-start-copy
record-start no longer fails copying a world the server is saving
2026-11-10 22:43:10 +00:00
rdbt 450a11d633 Merge pull request #14289 from senjinthedragon/ctm-shared-layers
Connected textures: a texture a rule matches keeps a layer of its own
2026-11-10 22:43:05 +00:00
rdbt cb0513691e Merge pull request #14288 from vnsmoreira/fix/vehicle-fall-clip-wait
command_exec: two boat groups wait for their entity-ticking neighbourhood
2026-11-10 22:42:58 +00:00
rdbt b36353a08a Merge pull request #14255 from Cx188/fix/8578-controlled-chunk-acks
Add a controlled fixed-64 chunk ACK mode to horizon-bots
2026-11-10 22:42:53 +00:00
rdbt aeca67e1e7 Merge pull request #13818 from RINNECODER/up12/cem-legacy-textures
OptiFine CEM: a pre-1.21.5 pack's old mob textures paint its models and the climate variants
2026-11-10 22:42:48 +00:00
rdbt 95cfe9ab5c Merge pull request #13816 from RINNECODER/up12/cem-this
OptiFine CEM: `this` is a replacing entry's own bone
2026-11-10 22:42:43 +00:00
rdbt 7b3a3d06fa Merge pull request #13727 from MSedgeMC/fix/dialog-book-pause
A dialog's pause and a lectern's book decide whether the world stops
2026-11-10 22:42:37 +00:00
rdbt d5f9b60203 Merge pull request #12642 from itarqos5/perf/particle-threads
Particles: tick the independent ones on several threads (stacked on #12641)
2026-11-10 22:42:31 +00:00
rdbt fbd69c11f1 Merge pull request #12449 from RINNECODER/up3/pack-compact-terrain
Shader packs: keep solid terrain in the compact layout
2026-11-10 22:42:27 +00:00
rdbt 6ed7be61d9 Merge pull request #11230 from Ragbie/fix/vehicle-item-stack-config
A placed or dispensed minecart or boat takes its item's name, custom data and entity_data
2026-11-10 22:42:01 +00:00
rdbt aff92ca2df Merge pull request #14504 from horizon-rust/ci/runner-watch
runner watch: one issue when jobs wait for self-hosted runners that are offline
2026-11-10 22:36:30 +00:00
rdbt 54ee56ea1c Merge pull request #14303 from NikitaMGrimm/agent-e2e-windows
agent_control_e2e passes on Windows again: screen commands wait for the first frame
2026-11-10 22:36:24 +00:00
rdbt 9221eaf7b5 Merge pull request #14323 from NikitaMGrimm/fix/layer-checks-merge-base
Local layer and address checks no longer fail a branch for entries main dropped after it
2026-11-10 22:36:21 +00:00
rdbt 7b42678bcd Merge pull request #14342 from NikitaMGrimm/prepared-data-stale-tests
Five prepared-data tests run again: datapack fixtures, the gateway test, HORIZON_TEST_RESOURCES
2026-11-10 22:36:17 +00:00
rdbt e7af6c5683 Merge pull request #14372 from Joansitoh/fix/14369-rotate-source
Fix /rotate relative angles to use the command source's rotation
2026-11-10 22:36:13 +00:00
rdbt 26f0f7b710 Merge pull request #14507 from horizon-rust/docs/operations-runbook
docs/operations.md: running Horizon by hand without agents or the maintainer's machines
2026-11-10 22:36:09 +00:00
rdbt 37518b7953 Merge pull request #14429 from rdbtCVS/perf/forced-cover-distance
Streaming: forced chunks by distance, one lookup per column instead of a scan
2026-11-10 22:34:12 +00:00
rdbt c5ebfaad17 Merge pull request #14316 from voduff/team-feedback-names
Scoreboard: /team and /scoreboard name holders and teams with vanilla's hover and insertion
2026-11-10 22:34:09 +00:00
rdbt 1ce50ca842 Merge pull request #13760 from TheMrZZ/fix/allay-jukebox-source-2
An allay dances to the jukebox it heard first
2026-11-10 22:33:43 +00:00
rdbt 03fe63b323 Merge pull request #14336 from voduff/map-moved-fns
port map: rows follow fns split into their own files
2026-11-10 22:33:40 +00:00
rdbt 0e9d4928a1 Merge pull request #14363 from voduff/map-stale-gaps-004
port map: golem, mooshroom, ocelot, phantom, snow golem render rows (client-004)
2026-11-10 22:33:37 +00:00
rdbt 10dd71a575 Merge pull request #14428 from DUzzL/fix/14235-plate-minecart
Fix a fenced minecart not re-triggering the pressure plate above it
2026-11-10 22:33:33 +00:00
rdbt f722c4455a Merge pull request #14453 from AfrobamaYT/parity-shim-loop
Parity: the java shim never starts itself, and stops with a reason if a wrapper loops it back
2026-11-10 22:33:29 +00:00
rdbt ff4dd57b32 Merge pull request #14456 from rdbtCVS/fix/armadillo-baby-follow-box
Armadillo: a baby follows only adults its sensor's box meets
2026-11-10 22:33:25 +00:00
rdbt 6e7600425c Merge pull request #14633 from NikitaMGrimm/parity/projectile-rotation-lerp
Thrown projectiles and llama spit turn a fifth of the way towards their motion each tick
2026-11-10 22:33:22 +00:00
41a579d428 test: cross parallel tick helper thresholds with real resources
Compare deferred and in-place metadata for 2,085 varied mobs, including pack-selected effect particles and switch-off vector reuse. Exercise live climbing, snapshot order and nearest-player invalidation for 293 mobs. Build 132 random-tick section masks ahead and retain exact event, state and random comparisons through edits and reloads.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-10 22:32:11 +00:00
rdbt 9ba4c83c35 Merge pull request #14385 from Akirabane/fix/9370c-skin-loader-bounds
Skin loader: bound its downloads, `states` and `resources`
2026-11-10 22:31:11 +00:00
rdbt 98d56df183 Merge pull request #14952 from masa-masa-0314/audit/realms-26.3-main
Realms: finish world extraction after cancellation
2026-11-10 22:31:07 +00:00
rdbt ec5b969a9c Merge pull request #14272 from NikitaMGrimm/parity-windows-builds
Local parity runs work on Windows: harnesses no longer fail with "Access is denied" rebuilding a running game
2026-11-10 22:31:04 +00:00
rdbt fddebc2c59 Merge pull request #14335 from Akirabane/fix/3823-fullbright-remote
Fullbright is held off on another's server; single player and a LAN host keep it
2026-11-10 22:30:37 +00:00
rdbt d4f99b0b3b Merge pull request #14343 from Akirabane/fix/12820-offline-own-cosmetics
Cosmetics: the own model on an offline-mode server is asked by the signed-in account
2026-11-10 22:30:33 +00:00
rdbt cc3b9703a4 Merge pull request #14346 from Akirabane/fix/9370a-pack-icon-budget
Pack screen: send each pack icon to the atlas once, not every frame
2026-11-10 22:30:30 +00:00
rdbt 85e2eddf52 Merge pull request #14353 from DUzzL/fix/14264-dragon-wing-armour
Charge the host's armour for a dragon wing hit
2026-11-10 22:30:26 +00:00
rdbt d907493401 Merge pull request #14355 from Evan-lhm/split/gfx-section-fade
gfx.rs: the chunk fade-in helpers move to gfx/section_fade.rs
2026-11-10 22:30:23 +00:00
rdbt 09ab1bf799 Merge pull request #14548 from DUzzL/fix/14443-minecart-soul-sand
Minecart: powered rail pushes a resting cart away from soul sand
2026-11-10 22:30:13 +00:00
rdbt c822ad5a5b Merge pull request #14603 from Mayonesu0/up-14475-firework-lifetime
A firework rocket draws its UUID, motion, lifetime and shot from one random
2026-11-10 22:30:08 +00:00
rdbt 550bfa1815 Merge pull request #14797 from foudretbr/fix/launcher-preview-lag-animating-items
fix(cosmetics): permit 60 fps drag pictures while animating items in viewer (#14656)
2026-11-10 22:30:04 +00:00
rdbt 9b6036466e Merge pull request #14854 from lucasmts59/fix/dialog-text-glyph-bounds
A dialog's body text is clicked where its glyphs are drawn
2026-11-10 22:30:00 +00:00
rdbt 9c1241b581 Merge pull request #15003 from MSedgeMC/fix/strider-lava-fall
A strider that lands in lava takes no fall damage
2026-11-10 22:29:57 +00:00
rdbt 9fb79e9ae5 Merge pull request #15005 from MSedgeMC/fix/sulfur-cube-silent-step
A sulfur cube carrying a block walks silently
2026-11-10 22:29:53 +00:00
rdbt ee286fe931 Merge pull request #14842 from foudretbr/perf/shader-water-patch-uniform-reads
perf(shaders): unroll boat water patch checks to avoid local array spilling (#14782)
2026-11-10 22:28:00 +00:00
rdbt 926790a953 Merge pull request #14810 from tarzinio/fix-particle-culling-answers
Keep answers of earlier particle culling jobs
2026-11-10 22:27:57 +00:00
rdbt 5991716f9b Merge pull request #14796 from DUzzL/fix/14753-cube-wasonground
Run AbstractCubeMob.tick for NoAI cubes so wasOnGround updates
2026-11-10 22:27:54 +00:00
rdbt 41bf552edc Merge pull request #14778 from DUzzL/fix/14752-minecart-pitch
Reset a minecart's pitch to 0 every tick like vanilla's old behaviour
2026-11-10 22:27:50 +00:00
rdbt 705826487f Merge pull request #14788 from NongTham/fix/14748-carved-pumpkin-swing
use_item: do not swing non-swappable equippables on empty target (fixes #14748)
2026-11-10 22:27:47 +00:00
rdbt 87327e8228 Merge pull request #14790 from zenith600/fix/hidden-processes-platform-path
Launcher: hidden_processes test names horizon-prepare through horizon-platform
2026-11-10 22:27:44 +00:00
rdbt 743c4af6a8 Merge pull request #14878 from NikitaMGrimm/hdr-calibrate-wording
HDR settings: the Calibrate row no longer says Windows' peak is too high
2026-11-10 22:27:40 +00:00
rdbt 8f761ea16e Merge pull request #14881 from Art34222/fix/full-detail-distance-chunks
Keep vanilla's chunk count on Full Detail Distance
2026-11-10 22:27:37 +00:00
rdbt 50be3bbb80 Merge pull request #15002 from MSedgeMC/fix/shelf-block-activate
port: a container writing a shelf's slot posts block_activate
2026-11-10 22:27:33 +00:00
rdbt d4e561413b Merge pull request #14903 from MSedgeMC/fix/recipe-book-narrow-escape
Escape closes the recipe book, not the screen, when it covers one
2026-11-10 22:27:00 +00:00
rdbt ce94025311 Merge pull request #14906 from MSedgeMC/ledger/lectern-maybuild
port map: the lectern's Take Book row names the build gate it already has
2026-11-10 22:26:57 +00:00
rdbt 885a98f2df Merge pull request #14915 from Detimes/perf-mob-near
Perf: raider companions, shout range and the cat bed check use the mob grid
2026-11-10 22:26:52 +00:00
rdbt a72bf4935b Merge pull request #14913 from Detimes/perf-piston-index
Perf: index moving-piston tickers by position
2026-11-10 22:26:49 +00:00
rdbt 4af062b305 Merge pull request #14912 from Detimes/perf-adv-tick
Perf: no advancement tick view without a listening criterion
2026-11-10 22:26:45 +00:00
rdbt a4b68b5d40 Merge pull request #14953 from Web-mc1/fix/far-tiles-div-zero
Fix div_ceil(0) when HORIZON_FAR_TILES=0
2026-11-10 22:26:42 +00:00
rdbt 14c1840967 Merge pull request #14960 from MSedgeMC/fix/pet-dead-attacker
A pet does not avenge a dead attacker
2026-11-10 22:26:39 +00:00
rdbt 7866f95d80 Merge pull request #14963 from MSedgeMC/fix/fox-sleep-inputs
A fox with movement input does not sleep
2026-11-10 22:26:35 +00:00
rdbt ab98b12b18 Merge pull request #14965 from DUzzL/fix/14833-phantom-pitch
Phantoms keep the pitch their move control sets
2026-11-10 22:26:32 +00:00
rdbt d7b00ddb4b Merge pull request #14966 from RLC248/fix/sulfur-cube-growth-age-crossing
A sulfur cube grows to size 2 only when it grows up
2026-11-10 22:26:28 +00:00
rdbt 9fcedb7da9 Merge pull request #14968 from DUzzL/fix/14908-furnace-neg-cooktime
fix(furnace): saturate idle cooking timer at a negative total instead of panicking
2026-11-10 22:26:25 +00:00
rdbt 2a17e04399 Merge pull request #14985 from MSedgeMC/fix/book-link-cursor
A link in a book asks for the pointing hand
2026-11-10 22:26:22 +00:00
rdbt bbe2b1be3c Merge pull request #14992 from senjinthedragon/vec4-uniforms
Shader packs: horizonDynamicLight* and horizonWaterPatch* are vec4s to the type check, not floats
2026-11-10 22:26:18 +00:00
rdbt 1b30bc56af Merge pull request #14983 from MSedgeMC/fix/suggestion-hand-cursor
The command suggestions ask for the pointing hand
2026-11-10 22:25:36 +00:00
rdbt ee2e56ced0 Merge pull request #14990 from DUzzL/fix/14909-shulker-neg-peek
fix(shulker): a negative Peek no longer panics the lid easing
2026-11-10 22:25:33 +00:00
rdbt 32a7bbc853 Merge pull request #14996 from KBroneZ/claude/fork-14503
Leak each distinct block registry once in Assets::load
2026-11-10 22:25:31 +00:00
rdbt a02e0034cf Merge pull request #15008 from MSedgeMC/fix/spider-vehicle-attachment-note
port_map: the spider's vehicle attachment names its rule
2026-11-10 22:25:19 +00:00
rdbtCVS 0b21fcbf60 Random: preserve the ordered lane and stream when reseeding
Treat LegacyRandom::set_seed as an ordered mutation: acquire the shared stream before replacing its LCG state and clearing the Gaussian cache, without discarding the gate. This also publishes a reseed-only item's state through end_item and keeps finish_lane and LaneDone working.

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

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: OpenCode GPT 6.1 Sol <[email protected]>
(cherry picked from commit 9298d7961c)
2026-11-10 22:08:34 +00:00
darkliroandAntigravity fb9b1d3542 threads: detect performance cores on Linux hybrid CPUs
Count performance cores on Linux systems with asymmetric core frequencies
(such as Intel Alder Lake, Raptor Lake, Meteor Lake and ARM big.LITTLE)
via sysfs cpufreq, bringing parity with the Apple silicon performance core
heuristics from #11294.

Co-Authored-By: Antigravity <[email protected]>
2026-11-10 23:00:02 +01:00
MSedgeMC 4d4aec1c7a fix: one rejected banner layer clears the saved list
`BannerPatternLayers.CODEC` is `Layer.CODEC.listOf()` and
`BannerBlockEntity.loadAdditional` resolves it with `ValueInput.read` plus
`Optional.orElse(BannerPatternLayers.EMPTY)`, so a single layer the codec
rejects leaves no layers at all. The branch kept the valid ones with a
`filter_map`, which is the partial behaviour the body says it fixed.

The list now reads all or nothing, and the test asserts the empty answer for a
mixed list, a missing field, and that a list of valid layers still reads back
(and round-trips), so an always-empty result cannot pass it.
2026-11-10 21:58:02 +00:00
DUzzL a8a5843f59 Mobs: tick freezing after the move and block effects, like LivingEntity.aiStep 2026-11-10 22:40:06 +01:00
MSedgeMC 4a93a8edcc port_map: the spider's vehicle attachment names its rule
The row pointed at passenger_attachment, which is the seat for riders on the
spider, and claimed the spider always uses its fixed seat. The rule is
vehicle_attachment: 0.3125 of the scale only when the vehicle's box is no wider
than the spider's, and a wider vehicle falls to the type's vehicle attachment,
EntityAttachment.VEHICLE's AT_FEET fallback, which is the feet.
2026-11-10 21:38:43 +00:00
MSedgeMC f09665e100 port_map: the loaded cube's silent step names its arm
SulfurCube.playStepSound had no row; it is the sulfur cube arm of step_sound.
2026-11-10 21:32:44 +00:00
MSedgeMC 41036d79a3 Test the loaded cube that steps silently
ai::step_sound over a stone block: a sulfur cube with a stone body answers
None, and an empty one answers block.stone.step at volume 0.15 and pitch 1.0.
Without the arm the loaded cube reads Some(("block.stone.step", 0.15, 1.0)).
2026-11-10 21:29:37 +00:00
MSedgeMC dba73d15d4 A sulfur cube carrying a block walks silently
SulfurCube.playStepSound returns before its super while the cube has a block
in its body, so a loaded cube makes no footstep at all; an empty one takes the
block's own step at 15%. step_sound had no sulfur cube arm, so a loaded cube
walked with block.stone.step like an empty one.

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

Horizon's `Game::view_changed` marked the shelf's chunk and re-read the
comparator, but posted no game event.
2026-11-10 20:58:07 +00:00
CocofireHDandDeepSeek V4.1 Flash 63a2feba79 A zombie villager's profession is the constructor's draw from its own random (#14857)
`ZombieVillager.defineSynchedData` seeds `DATA_VILLAGER_DATA` from
`initializeZombieVillagerData(this.random)`: one `nextInt(15)` on the entity's
own random, made inside `Entity`'s constructor. Horizon built a synthetic
`LegacyRandom` out of the mob's random state and id instead, so the profession
never matched vanilla's and the mob's own random never advanced.

The draw also has to sit in the right place in the stream. `Entity.<init>`
makes the UUID's two draws and then calls `defineSynchedData` last, while the
yaw `living_random` used to take belongs to `LivingEntity`'s constructor, after
it. So `living_random` now returns the random straight after the UUID, and the
two draws it left behind move to `zombie_villager::constructor_draws`, which
`Mob::with_random` calls in vanilla's order. Nothing moves for other kinds:
those draws are constants, so their streams are unchanged.

Two draws vanilla never makes are gone as well. `ZombieVillager.finalizeSpawn`
only picks the villager type and never rolls a profession, and neither does the
spawn path. `readAdditionalSaveData` redraws only for a finalized save with no
`VillagerData`, which `read_nbt` now matches.

Verified with the issue's repro (`parity_fuzz.py --replay`, seed 998): vanilla
`butcher` against Horizon `nitwit` before, both `butcher` after. The replay's
other difference, `Motion[1]` -0.0784 against 0.0, is pre-existing on
`origin/main` and untouched here. `seed_sync`, `zombie_cure_sound` and
`world_spawn#1`-`#4`/`world_spawn_sync` pass.

`mobs.rs` is past the 3000-line limit, so it gains no lines: the helper lives in
`zombie_villager.rs` and `living_random` loses the yaw line it used to take.

Co-Authored-By: DeepSeek V4.1 Flash (OpenCode) <[email protected]>

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-10 21:38:36 +01:00
SenjinandClaude Opus 5.5 db8460de3a Shader packs: horizonDynamicLight* and horizonWaterPatch* are vec4s to the type check: a pack declaring them read 0
The type check (beb30045fd) keeps a built-in only as the type it gives, sized by components(), which
took Horizon's own vec4s for floats: a pack declaring horizonDynamicLight<n> (#12069) or
horizonWaterPatch<n> as vec4 had them switched off and read 0. Their counts stay ints.

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

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

Co-Authored-By: ZCode <[email protected]>
2026-11-11 02:44:22 +08:00
MSedgeMC 3eaaf3070c port_map: map FoxSleepGoal.canUse movement inputs 2026-11-10 18:42:21 +00:00
MSedgeMC 74abfb6fa7 test: a fox with movement input does not sleep 2026-11-10 18:42:21 +00:00
MSedgeMC 0990beeb2b fix: a fox needs no movement input to sleep 2026-11-10 18:42:06 +00:00
MSedgeMC 4a73bc0b34 port_map: map OwnerHurtByTargetGoal.canUse target validity 2026-11-10 18:41:04 +00:00
MSedgeMC e631c28418 test: a pet does not avenge a dead attacker 2026-11-10 18:41:04 +00:00
MSedgeMC 4871665711 fix: a pet does not avenge a dead attacker 2026-11-10 18:40:49 +00:00
Oloion 2e6f3cd716 client: show custom server crafting recipes in recipe book (fixes #14608) 2026-11-10 21:32:19 +03:00
DetimesandClaude Opus 5.5 594e8622a7 Merge upstream/main into perf-mob-near
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:29:34 +00:00
DetimesandClaude Opus 5.5 ea212b9152 Merge upstream/main into perf-piston-index
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:25:11 +00:00
DetimesandClaude Opus 5.5 289e90d636 Merge upstream/main into perf-replay-excuse
Brings the branch up to date and lays out the new code and tests as
rustfmt wants them, which the verify job checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:16:08 +00:00
Mayonesu0andClaude Opus 5.5 3275cd1602 Dispensers pick players in getEntitiesOfClass order, loaded or not
Players are entities of the level from placeNewPlayer/respawn on, so a
guest whose client is still loading is equipped too, and guests and the
local player sort with the mobs and stands: entity sections first
(EntitySectionStorage.forEachAccessibleNonEmptySection, SectionPos.asLong
order), then join order by entity id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:15:33 +01:00
Mayonesu0andClaude Sonnet 5.5 a100957b30 Dispensers equip server players and respect armor stand slot locks
EquipmentDispenseItemBehavior picks the first living entity in front that
canEquipWithDispenser, players included; ArmorStand.canUseSlot rejects locked
slots and hands without arms. Fixes #12937

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 19:15:33 +01:00
masa-masa-0314andZCode 3dcdbf8947 docs: ground Friends overlay split plan in 26.3 source review
Replace the open-ended UI scope with eight reviewable work packages and
separate backend, toast, face and host-adapter steps. Record vanilla
entry/lifecycle requirements, old-port defects, evidence limits and the
maintainer decisions required before implementation.

Keep the account overlay distinct from the approved host/share migration;
leave the old implementation PR and isolated merge branch untouched.

Co-Authored-By: ZCode <[email protected]>
2026-11-11 02:01:45 +08:00
DUzzL 5da9065a8d Start the cure when a zombie villager loads a ConversionTime
ZombieVillager.readAdditionalSaveData calls startConverting, so a saved cure swaps weakness for strength on load.
2026-11-10 18:56:44 +01:00
MSedgeMC a04de39fea port_map: CrafterBlockEntity.fillStackedContents is a port
The crafter's nine slots are the menu's MAIN container and count for the
recipe book's craftable check.
2026-11-10 17:45:24 +00:00
MSedgeMC 76d95dbcc9 test: a crafter's grid is stacked for the recipe book
`a_crafters_grid_counts_for_the_recipe_book` opens a crafter menu, puts
four oak planks in its first slot and asks the stacked contents whether
an oak-plank ingredient can be crafted. Without the crafter arm the same
call fails.
2026-11-10 17:45:13 +00:00
MSedgeMC b9ad2da17e fix: a crafter's grid counts for the recipe book
`CrafterBlockEntity.fillStackedContents` hands the crafter's nine slots
to the recipe book's stacked contents (`accountSimpleStack` each), so an
open crafter's grid counts towards the craftable check.

Horizon's `Menu::stacked_contents` read the player's inventory, the mod
sources and the `GRID` container, but a crafter's slots are its `MAIN`
container (`CrafterMenu.addSlots`): with a crafter open the book saw an
empty grid and marked nothing craftable.
2026-11-10 17:45:13 +00:00
DetimesandClaude Haiku 5.5 fab1e1a3e6 Test: entity_near matches the scan it replaced
Differential test over 2000 seeded random inputs: entity sets of up to 40
states (all four kinds, own and other ids), guest positions and slacks,
with entities placed on or next to every edge of the close box, checked
against a verbatim copy of the old scan. Plus an inclusive-edge test and
the own/item/orb exclusions. The inputs are multiples of 1/8 so the box
edges are exact in f64. No world or game resources needed; cargo not run.

Refs #14920

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:41:19 +00:00
DetimesandClaude Haiku 5.5 f6d8444698 Perf: test the distance before the kind in the replay's entity excuse
excused() ran a string compare on each entity's kind before the box test,
for every guest every tick. Move the box test first (it is a pure float
check) and move the entity scan into entity_near, so the excuse can be
checked against the old scan. The boolean result is unchanged: the
predicates are pure and any() is order-independent.

Refs #14920

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 17:40:37 +00:00
DUzzL a0677225dd Phantom: empty look control keeps the pitch its move control sets 2026-11-10 18:31:53 +01:00
MSedgeMC 7950bfbc6f port map: the lectern's Take Book row names the build gate it already has 2026-11-10 17:29:49 +00:00
MSedgeMC b5a64cf86b port_map: JukeboxBlockEntity.notifyItemChangedInJukebox is a port
A disc coming or going sets `has_record` and posts `block_change` at the
box; the row kept the old gap.
2026-11-10 17:24:58 +00:00
DetimesandClaude Opus 5.5 c9cd62bd5b Keep game.rs from growing: the tick-view gate lives with the criteria helpers
game.rs is over the 3000-line limit, so the gate and its test move into
native_criteria.rs, which already builds the player's advancement views,
instead of a new module declared in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:21:28 +00:00
MSedgeMC 0966aa0d3f test: a disc change posts block_change
`a_disc_change_posts_block_change` places a jukebox, records the posted
game events and calls `set_has_record`: the box posts
`minecraft:block_change` at its centre with no source. Without the post
the recorded list is empty.
2026-11-10 17:15:39 +00:00
Nikita Maximilian GrimmandClaude Fable 5.2 9188926fff Shader API 0.2: [frame] primaries, and the rule for new names
A 0.2 pack can't show a colour outside sRGB today: the contract says every
colour is in BT.709 primaries, forever. This adds the key that will let a
pack ask for more, before 0.2 is on by default and packs are written against
that contract.

- `[frame] primaries = "bt709"` (the default) or `"bt2020"`. The check takes
  `"bt2020"` only with `blend = "linear"`, since a change of primaries is only
  right in linear light. The game refuses a `"bt2020"` pack for now, as it
  refuses `blend = "linear"`.
- `Frame` gains `primaries` and `luminance` (that space's luminance weights)
  after `black_level`. The renderer writes 0 and BT.709's weights. The
  uploaded buffer grows by 16 bytes; 0.1 declares the same prefix as before.
- The reference's "Room to grow" says a later release adds a name to a
  stage's prelude only under a manifest key the pack sets or in a new stage.
  `tests/names.rs` holds each 0.2 prelude to its list of names
  (`tests/names-0.2/`), and shows why: a pack's own `fn depth_at` in a post
  stage is refused as a redefinition, its own `fn to_bt709` passes.

No pack's picture changes: a pack without the key, a `"bt709"` pack and 0.1
packs draw as before.

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

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 18:13:34 +01:00
MSedgeMC b920f8a432 fix: a disc coming or going posts block_change
`JukeboxBlockEntity.notifyItemChangedInJukebox` sets the `has_record`
block state with update flag 2 and then calls
`level.gameEvent(..., GameEvent.BLOCK_CHANGE, pos)`: sculk sensors hear a
disc enter or leave a jukebox. Horizon's `set_has_record` changed the
state but posted no game event.
2026-11-10 17:09:19 +00:00
Florian ErnstandClaude Opus 5.5 16fb5cfed0 Slot sources: holder and tag codec, and slot-source tags in DataStore
SlotSources.CODEC admits a registered holder (`ns:id`), a holder list and a
`#tag` holder set wherever a slot source is accepted, and validates the
`group`, `limit_slots`, `filtered`, `contents` and `slot_range` shapes
(including a `slot_range` source context) against the loaded registry.

DataStore loads `tags/slot_source` in the same pass as the definitions, in
pack order with `replace`. `slot_source_tag` resolves a tag as TagLoader
does: first-seen member order, nested tags, required members and cycles
rejected. The command registry publishes the tags that resolve.

Part 1 of 3 for #13091 (PR #13951 is the last part).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:04:33 +01:00
TheMrZZandCodex gpt-6.1-sol ef9f8e86d7 Keep item-count and shulker harness registrations merge-compatible
Share only isolated-group/filter metadata; each PR keeps its own corpus and gameplay change. Preserve hopper-pot exact selection and all existing groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:04:14 +01:00
TheMrZZandCodex gpt-6.1-sol b91c2d4965 Keep item-count and shulker harness registrations merge-compatible
Share only isolated-group/filter metadata; each PR keeps its own corpus and gameplay change. Preserve hopper-pot exact selection and all existing groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:04:12 +01:00
TheMrZZandCodex gpt-6.1-sol 1cf4775de9 Merge main into PR13762 to pick up landed CI fixes
Preserve maintainer merge history; include deterministic git-slim temporary repository maintenance settings from #14090.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-10 18:02:06 +01:00
TheMrZZandCodex gpt-6.1-sol bdf670abdf Merge main into PR13761 to pick up landed CI fixes
Preserve maintainer merge history; include deterministic git-slim temporary repository maintenance settings from #14090.

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

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

Fixes #14880

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

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

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

Part of #12989.

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

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

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

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

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

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

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

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

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

Closes #14503

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:00:24 +02:00
foudretbr 55509cffaf perf(shaders): unroll boat water patch checks to avoid local array spilling (#14782) 2026-11-10 16:32:04 +01:00
readerkid 327b811914 Map CEM's armour models to the layers 26.3 draws, and the rabbit's feet
CEM names a humanoid's armour with two models, `<mob>_outer_armor` and
`<mob>_inner_armor`, but 26.3 draws it on four layers of the model: the helmet,
the chestplate, the boots and the thinner leggings. A CEM name gave one layer
key, so no armour file reached any of them, and the 24 armour layers of the
zombie family's baby models were unreachable.

An armour model now reshapes every layer it covers: the outer one the helmet,
the chestplate and the boots, the inner one the leggings. Layers a model does
not have are dropped by the existing check against vanilla's layers, so no list
of mobs has to track the game, and a baby keeps its own file through the suffix
rule.

The rabbit's `left_foot`/`right_foot` mapped to `left_hind_foot`/
`right_hind_foot`, which 26.3 no longer has: the haunch carries the box under an
empty hind leg, so the foot is the haunch and the thigh the leg.

`mobs::cem::tests::names_reach_horizons_models` (ignored: needs the prepared
resources) passes with no gaps.

Fixes #8426

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-10 07:16:11 -08:00
tarzinio 58d23e2654 Keep answers of earlier particle culling jobs 2026-11-10 14:25:11 +01:00
foudretbr fc51d09b64 fix(cosmetics): permit 60 fps drag pictures while animating items in viewer (#14656) 2026-11-10 13:11:08 +01:00
NongTham e1848e08a4 input: clear modifiers on focus loss and heal alt and super keys (fixes #14715) 2026-11-10 19:10:27 +07:00
DUzzL b782c0d9b5 Run AbstractCubeMob.tick for NoAI slimes, magma cubes and sulfur cubes 2026-11-10 12:56:20 +01:00
foudretbr 3661bfd7c2 fix(game): evaluate attack_speed attribute modifiers for player attack cooldown (#14721) 2026-11-10 12:55:27 +01:00
NongTham 4264ba0c4e use_item: do not swing non-swappable equippables on empty target (fixes #14748) 2026-11-10 18:50:05 +07:00
MSedgeMC a08205b8ec port_map: MerchantScreen.extractScroller is ported 2026-11-10 11:44:30 +00:00
MSedgeMC 41fdaa3f0c test: the merchant scroller's thumb asks for its cursor
Eight offers put the thumb at the track's top; a hand over it, the vertical
resize while dragged, nothing left of the thumb or past its bottom edge, and
the disabled scroller of seven offers asks for nothing.
2026-11-10 11:44:30 +00:00
MSedgeMC 2398317072 fix: the merchant's trade scroller shows its cursor
MerchantScreen.extractScroller requests CursorTypes.POINTING_HAND over the
scroller's thumb, or RESIZE_NS while it is dragged. Horizon drew the thumb
but asked for no cursor, so the pointer stayed an arrow over it.
2026-11-10 11:44:30 +00:00
DUzzL 36fb430fbc Reset a minecart's pitch to 0 every tick like OldMinecartBehavior 2026-11-10 12:30:47 +01:00
MSedgeMC 669e1a162a Move takes_food into mobs/ai/feeding.rs so ai.rs may not grow 2026-11-10 11:29:46 +00:00
zenith600andClaude Opus 5.5 0ea7d1707c Launcher: hidden_processes test names horizon-prepare through horizon-platform
The Windows-only test still built `horizon_prepare::java::Jdk`, but since
92886cd964 the launcher reaches that crate only through horizon-platform's
`prepare` feature, so `cargo clippy --all-targets` in launcher/ failed on
Windows (Linux CI never compiles this file). Use
`horizon_platform::prepare::java::Jdk`, as backend.rs does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:55:46 +05:30
NongTham d645b72e8b test: allow-flight in bed_explosion parity harness (fixes #14664) 2026-11-10 18:05:15 +07:00
Loki 3bc4e3035c Merge remote-tracking branch 'origin/main' into fix/12935-sculk-particle-resume-reload 2026-11-10 19:47:20 +09:00
foudretbr 15183261d9 fix(input): reset debug keys modifier state on window focus loss (#14715) 2026-11-10 11:25:52 +01:00
Ludovic Marie 8933e8c2d8 Merge pull request #14741 from horizon-rust/governance/elections-tiers
🤖 Agent: founder-imposed governance amendment of 2026-11-10 (Ludovic's go, ~09:55Z): three kinds of action, elections by tier, the permissions table checked against the live Discord, panel, ops list and GitHub, and P41 with a 24 h veto; docs only, verify green on this head. The council can overturn it by poll.
2026-11-10 11:25:36 +01:00
MSedgeMC adbc523e5c port_map: the canAgeUp feeding branch
AgeableMob.canAgeUp to ai.rs::can_age_up, and Animal.mobInteract's food
branches to ai.rs::takes_food: an adult at age 0 that canFallInLove, else
canAgeUp.
2026-11-10 10:20:16 +00:00
MSedgeMC 7f5b3462df test: feeding a frog at a baby's age leaves the food in the hand
A frog and a cow, both at Age -24000, through takes_food and feed_animal: the
frog refuses the food and keeps the age, the cow takes it and grows a tenth.

Without the fix the frog takes it: took_food=true ate=true age=-21600, where
the test wants false, false and -24000. The cow reads the same either way
(age -21600).
2026-11-10 10:20:13 +00:00
MSedgeMC c106b315ad fix: a mob that cannot be a baby does not take the food that grows it
Animal.mobInteract's growing branch is `canAgeUp()`, which is
`isBaby() && !isAgeLocked()`, not `getAge() < 0`. ai::takes_food still
decided that branch from the raw age, so a frog spawned with Age at
getBabyStartAge (AgeableMob.finalizeSpawn's group baby is not gated on
canBeABaby), a camel husk (AbstractHorse's group spawn is 20 percent), or a
slime, magma cube, parrot or zombie nautilus with a negative age took the
food: it ate the item, played the eating sound and grew a tenth towards 0,
where vanilla refuses the click.

takes_food now asks can_age_up(m), the port of canAgeUp, the same predicate
the rest of the feeding code already used.
2026-11-10 10:20:06 +00:00
Horizon agentandClaude Opus 5.5 226a707d0d GOVERNANCE.md: three kinds of action, elections by tier, who may do what, P41
Founder-imposed amendment of 2026-11-10. Free actions need no vote; a role
with power or a body's own decision is a 24-hour body vote with a 24-hour
council veto; everything else stays a council poll (48 h only for this
document, the licence, money and org ownership). Adds the election table,
its hard cases and Kira's reminders (5.3), the permissions table checked
against the live Discord, panel, ops list and GitHub teams (2.2), and P41:
the mods decide moderation matters, council veto 24 h (section 7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:06:36 +01:00
1d9040c05a Silence a chest's comparator while a cat sits on it
ChestBlock.isChestBlockedAt is a redstone conductor above the chest or a
cat sitting in the block above it (ChestBlock.java:318-319, the cat test
at :327-338). getAnalogOutputSignal (:346-348) reads getContainer(..,
false), whose combine(.., false) (:279) uses that predicate, so a blocked
chest combines to nothing and the comparator reads 0.

The comparator's container view (machines::Containers::view) only checked
the conductor; the cat test lived on the menu path alone. Hand the level's
mobs to the view and share one isCatSittingOnChest helper with the menu
path (chest_blocked_at). A hopper's getContainer(.., true) still ignores
the block, so hoppers and menus behave as before.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 09:19:23 +01:00
Skr1zzzzandClaude Opus 5.5 a35b729ccf Keep game.rs from growing: the falling model cache takes the start position
`falling_model_vertices` works out the model seed from the block's starting
position itself, so its call in the falling-block loop fits on one line and
game.rs ends up shorter than on main (docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 11:14:38 +03:00
LokiandAntigravity 27cded80d5 mobs: apply squid sinking velocity on land under NoAI (fixes #14467)
Co-Authored-By: Antigravity <[email protected]>
2026-11-10 15:36:11 +09:00
masa-masa-0314andZCode 64e74bffd2 docs: propose friends overlay Part 2 spec
Co-Authored-By: ZCode <[email protected]>
2026-11-10 14:19:11 +08:00
Nikita Maximilian GrimmandClaude Fable 5.2 db26868e4f HDR10 output: the last pass dithers its 10 bits
The float frame is smooth, but the last pass rounded it to the HDR10
swapchain's 10 bits as it was. Around 90 nits one PQ code is a 1% step
in light, and a clear noon sky is two or three codes tall, so it showed
as wide flat bands with hard edges (#12989's audit, F3).

The pass into the HDR10 swapchain now adds triangular noise of one code
either way to the PQ signal, each channel's own, as the last thing
before the write: over the world and the HUD alike, after the black
level lift. A gradient shows as grain one code deep and its mean is the
light meant.

- The noise is a fixed function of the pixel (PCG's 3D integer hash):
  no frame counter, state or texture, so a still picture stays still
  and HORIZON_STILL needs nothing.
- Black stays black: within a code of 0 or of the top the noise shrinks
  to what fits, so a signal of 0 is written as 0, one of 1 as 1023,
  nothing leaves the range, and near-black isn't lifted.
  In that last code the rounded mean leans to the end (a third of a
  code from it shows as an eighth): the accepted price of a true 0.
- A pipeline override constant (`pq_dither`) turns it on for the HDR10
  swapchain's pipeline alone. scRGB (a float swapchain), SDR, the SDR
  screenshot, recordings and the screenshot's HDR10 copy (`-hdr.png`)
  are as before, byte for byte; calibration's test pictures keep their
  exact codes.

Refs #12989

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 06:45:33 +01:00
5cfbc7e3ea Stop horses, llamas, camels and flying monsters climbing vines and ladders
AbstractHorse.onClimbable (horses, donkeys, mules, the undead horses, llamas,
trader llamas, camels, camel husks), Ghast.onClimbable, Phantom.onClimbable
and HappyGhast.onClimbable all return false, so LivingEntity never takes its
climbable branch for them: no 0.15 clamp and fall reset in handleOnClimbable,
no 0.2 climb against a wall in handleRelativeFrictionAndCalculateMovement, no
fall reset for a ridden mount in handleMoveVehicle, and isPushable keeps them
pushable in vines. Mob::on_climbable now answers that false, which every
caller (travel, pushEntities, the vehicle fall reset) already reads.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 06:36:40 +01:00
3f1f2fd3a8 Spawn phantoms under a glass roof
PhantomSpawner.tick asks Level.canSeeSky, which is "sky light 15 here"
(BlockAndLightGetter.canSeeSky), not a scan for a full collision cube:
glass and stained glass keep the sky light and spawn phantoms, while
stone, water and a slab roof do not. The advancement location predicate
read the same hand-rolled scan and now reads the sky light too.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 05:50:01 +01:00
YourPancakes ab7d9f4c50 perf(world): avoid full-cube allocation in collision checks 2026-11-10 07:18:19 +03:00
YourPancakesandChatGPT 4f1b40d9ca fix(render): keep binding cache out of oversized gfx.rs
Co-Authored-By: ChatGPT (GPT-6) <[email protected]>
2026-11-10 07:12:21 +03:00
YourPancakes 81a8d04a1f perf(render): cache flat compute bind groups 2026-11-10 06:55:35 +03:00
Vinícius MoreiraandClaude Opus 5.5 e1a6c5e363 A dispensed item draws its constructor's motion from its own random
DefaultDispenseItemBehavior.spawnItem builds the item with the
five-argument ItemEntity constructor, which draws a default motion (two
nextDouble) from the item's own random before spawnItem sets the thrown
one. Horizon passed the thrown motion straight in and skipped those draws,
so every later draw from the item's random, starting with
moveTowardsClosestSpace's escape speed, came two doubles early.
spawn_item_thrown makes the draws; the dispenser, the vault and the trial
spawner use it.

Fixes #14537

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:30:20 -03:00
YourPancakes 89733192ca perf(render): evaluate sky octagon with half-planes 2026-11-10 06:26:52 +03:00
b973a2ed28 Post block_place when turtles and frogs lay eggs, and stop bred turtles bursting hearts
Turtle.TurtleLayEggGoal sets the egg then posts GameEvent.BLOCK_PLACE, and its lay-egg pitch comes from level.getRandom(); TryLaySpawnOnFluidNearLand posts the same event for frogspawn. Turtle.TurtleBreedGoal.breed overrides Animal.breed, so Animal.finalizeSpawnChildFromBreeding's entity event 18 never runs for a bred turtle (Frog.spawnChildFromBreeding still calls it).

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:13:00 +01:00
Evrim CicekandClaude Opus 5.5 03a6255967 pad::parity_tests: pad_press records the press as pad_button does (#14648)
Since the radial menu, Pad::holds_action reads the actions pad_button
records for a pressed button; the helper called pad_action directly, so
four tests read a held action as not held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:53:11 +03:00
6539061751 Sneaking keeps the held food off campfires and candles answer pick-block
ServerPlayerGameMode.useItemOn skips the block's useItemOn while sneaking
with something in either hand, so CampfireBlock.useItemOn never places the
food; the item's own use (eating) runs instead. The block use it does
consume triggers ITEM_USED_ON_BLOCK, which the campfire placement now fires
too. CampfireBlock.douse and AbstractCandleBlock.extinguish post
BLOCK_CHANGE after the extinguish sound, so water on a lit campfire or a
lit candle is heard by sculk sensors, and CandleCakeBlock.getCloneItemStack
answers a cake for the plain candle cake as well as the dyed ones.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 03:52:09 +01:00
DetimesandClaude Haiku 5.5 7059358371 Catalog test: accept living_death_message as a free function
living_death_message is a free function in horizon_client_lib::living_death,
so the test needs a set for that module like hunger, landing and chat.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-10 01:52:12 +00:00
Evrim CicekandClaude Opus 5.5 d05c54b48e TNT silence tests run on an 8 MiB stack
Fixes #14628: the five source_silence_game tests overflowed the 2 MiB
test thread in a Windows debug build (4 MiB is enough) and took the whole
horizon-client lib run down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 04:29:15 +03:00
9f9d226294 Post a shulker teleport's game event for sculk sensors
Shulker.teleportSomewhere calls level.gameEvent(GameEvent.TELEPORT,
oldPos, Context.of(this)) after setPos and before level event 2016. Emit
the matching MobEvent at the old block's centre so sculk sensors and
wardens hear the teleport.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 02:27:58 +01:00
MrCloudy2andClaude Opus 5.5 0a9ceaedb2 Every wind burst of a tick pushes an entity that ticks after the projectiles
apply_late_wind_pushes gave each entity only the first push held for it.

Harness: tools/test_wind_bursts_all_push_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:50 +01:00
MrCloudy2andClaude Opus 5.5 d64e5172b6 A flying projectile is pushed by the water and lava it touches
Entity.baseTick's updateFluidInteraction applies the fluids' currents to a
thrown or hurting projectile (Entity.isPushedByFluid); only arrows had it.

Harness: tools/test_projectile_fluid_current_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:49 +01:00
MrCloudy2andClaude Opus 5.5 faa8141c2f A cactus pricks a boat or a minecart a piston pushes
moveEntityByPiston's applyEffectsFromBlocks reaches CactusBlock.entityInside,
even for a push that moves the vehicle nowhere. The pricked vehicles are
hurt once the tick's pushes are over, so the targets found first stay valid.

Harness: tools/test_piston_cactus_vehicles_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 02:12:42 +01:00
MrCloudy2andClaude Opus 5.5 2d5e7839c6 A projectile hits primed TNT (PrimedTnt.isPickable)
Primed TNT joins the boats and minecarts in the list of objects every
projectile, the trident included, can strike: a wind charge bursts on it, a
snowball breaks on it, an arrow or a trident turns back.

Harness: tools/test_projectile_hits_tnt_parity.py.

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 02:07:51 +01:00
Detimes 7a47e7a21f Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-10 01:06:25 +00:00
188817342b Firework rockets: honour Silent, post EXPLODE, count block launches
`FireworkRocketEntity.explode` posts `GameEvent.EXPLODE` from `getOwner()` at
the rocket before the damage, and the entity event passes `!isSilent()` to
`createFireworks`, which gates only the blast and the twinkle of
`FireworkParticles.Starter`, so a silent rocket keeps its sparks and flash.
A rocket launched from a block face also counts as a use, as
`ItemStack.useOn` does for `FireworkRocketItem.useOn`'s SUCCESS.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 02:05:57 +01:00
Detimes 56c7214447 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-10 00:54:38 +00:00
Skr1zzzzandClaude Opus 5.5 c1d32a0f93 Falling blocks: cache each model's vertices instead of rebuilding them every frame
The falling-block loop rebuilt every block's model vertices each frame: the
model lookup, two sprite hash lookups per face, shading and uv mapping. They
depend only on the block state and the model variant picked, so they are now
kept in model space per state and variant; each frame copies them, applies the
same translation as before and sets the block's light. The output is the same,
bit for bit.

8,100 falling sand blocks (fast profile, Windows 11, Ryzen 5 7500F, RTX 3070 Ti):
151 -> 247 fps, CPU 6.6 -> 4.0 ms per frame.

Part of #13007

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 03:50:36 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 861914b486 Merge origin/main into parity/bucket-mob-from-bucket
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:46:34 +01:00
foudretbr d1ca042ab8 fix(mobs): default cat variant to black when omitted (#14540) 2026-11-10 01:45:52 +01:00
Detimes 1e66c75589 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-10 00:40:40 +00:00
Detimes bd6bf2422b Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-10 00:25:43 +00:00
MSedgeMC 72879fc35b port map: point citations at the modules they moved to
The game, entity_nbt, stations, creatures, minecarts, particles and remote modules were split this week; twenty rows
still named the file a function left.
2026-11-10 00:17:52 +00:00
Evrim CicekandClaude Opus 5.5 5f0ea2347a horizon-discord: #[cfg(unix)] back on the socket test
Fixes #14568: 62e62c21e6 put the switch test between the socket test's
attribute and the test, so the socket test (std::os::unix) compiled on
Windows and the switch test ran only on unix.

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

Fixes #14544

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:09:21 +01:00
Riccardo GuerrieroandClaude Opus 5.5 5f40c762f6 Move the bred offspring's draws and their test out of creatures.rs
creatures.rs is over the 3000-line limit (docs/layers.md#file-size): the
coat coin, the wolf pup's voice and the test of both move to
game/creatures/bred_draws.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:03:44 +01:00
46988764a5 Bred wolf pups get a random voice, and pups take their coat coin from the parent
Wolf.getBreedOffspring picks the pup's voice with
WolfSoundVariants.pickRandomSoundVariant from the breeding parent's random,
after the collar colour mix; Horizon left every bred pup on the classic
voice. The cow, pig, chicken, fox, cat and wolf getBreedOffspring also draw
the coat coin (which parent's variant the baby gets) from the parent's own
random, where Horizon spent a level draw for every species but the
mooshroom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-10 01:03:38 +01:00
valuttaandClaude Opus 5.5 2289b7a732 AGENTS.md: how an agent reviews, after rdbtCVS's review prompt
Asked by a maintainer to review open pull requests, an agent takes them
one at a time, oldest first, skipping drafts, its person's own and ones
already reviewed. It checks function, regressions, tests and scope,
reports only points with a file:line, each marked by severity, and
submits Approve or Request changes, never a plain comment. Based on the
review prompt rdbtCVS shared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:57:43 +01:00
voduff d68f241a0e Merge remote-tracking branch 'origin/main' into fix/12038-cube-body-item-replace 2026-11-09 23:52:09 +00:00
valuttaandClaude Opus 5.5 176f5b3475 AGENTS.md: review pull requests only when asked, post only findings
An agent asked for some reviews swept about 300 open pull requests, most
reviews saying it found nothing. Agents now review a pull request only
when its author or a maintainer asks for it, post only findings someone
can act on as a real review, and skip reviews that found nothing.
Maintainers' own agents may run wider passes when the maintainer asks.

Closes #14545.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:48:26 +01:00
MSedgeMC 34d39e0da0 Rebuild the weapon wear read on current main 2026-11-09 23:48:14 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 a45aa615ef A mob from a bucket is not persistent, a sulfur cube keeps its bucket's age, and tadpoles never despawn
MobBucketItem.spawn loads the bucket's entity data and sets fromBucket,
never PersistenceRequired. SulfurCube.loadFromBucketTag reads its own
lowercase age keys, and Tadpole.fromBucket is always true, so
requiresCustomPersistence keeps every tadpole.

Fixes #14538

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:42:55 +01:00
MSedgeMC 9e7fc5f18c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 23:41:45 +00:00
72fe5b13ba Mobs: the mob loop's body moves into a group of mobs, still one thread
The per-mob turn of tick_creatures (its world view, brain, movement, events,
riders, deaths and pushes) moves into creatures/mob_group.rs as Group::tick, on
a Group that owns the mobs it ticks, their snapshot grid, pushes, events and the
level random. tick_creatures builds one group of every mob and runs it in list
order, so nothing changes: whether a mob is loaded and ticking, and the
per-chunk difficulty, are still decided at the mob's own turn. The parallel tick
(#14457) will run several groups. creatures.rs shrinks by 177 lines.

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:24:28 +00:00
MrCloudy2andClaude Fable 5.2 c7ca421167 A geyser goes through scaffolding seen from below
PotentSulfurBlockEntity reads its column with
CollisionContext.positionContext(the height under it); the shapes were read
with no context, so scaffolding kept its full top and stopped the geyser.

Harness: tools/test_geyser_scaffolding_parity.py.

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

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

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

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

Taken from kacpherek's #13644.

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

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

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

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:20:11 +00:00
Riccardo GuerrieroandClaude Opus 5.5 c1540bd026 Keep animal_brain.rs at main's line count
The file is over 3000 lines and may not grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:17:15 +01:00
8683a6b570 Post sniffer digging vibrations and mute the right sniffer sounds
Sniffer.emitDiggingParticles posts GameEvent.ENTITY_ACTION at the head block every ten ticks for the whole dig, outside the dust window. Only sounds that go through Entity.playSound are silenced: the state transitions and the seed drop, not playLocalSound's searching sound or the block's hit sound. A bred pair's egg takes its push from ItemEntity's own random.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 00:14:57 +01:00
DetimesandClaude Haiku 5.5 d88c0d9011 Adapt living death message test impls to the generated hook shape
The generated Hooks trait takes the event only: entity, kind and message are
its fields, so the test impls take one argument.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:06:25 +01:00
Ludovic Marie 1453e0e614 Merge pull request #13606 from martipops/controller/dial-4-editor
🤖 Agent: the editor builds on #13605's dial; main and #13605's menu_style colours merged in, clippy + 213 radial/menu/options/settings tests pass.
2026-11-10 00:05:14 +01:00
Ludovic Marie 9b4258fecc Merge pull request #13605 from martipops/controller/dial-3-play
🤖 Agent: main merged after #13958: the dial's colours now come from menu_style (page, backdrop_muted, and new lit/lit_edge that Horizon's look fills with the night accent), so the renderer keeps no horizon-theme dependency and the dial looks the same; its curves are cubic-bezier points pinned to horizon_theme by a test. split-controllers waits on this before moving controller files.
2026-11-10 00:05:08 +01:00
DetimesandClaude Haiku 5.5 9f861b1624 Use contains_key in entity lookup test (clippy unnecessary_get_then_check)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 23:04:34 +00:00
DUzzL 6a4ea1890f Minecart: resting cart on a powered rail reads the block's redstone-conductor flag (soul sand) 2026-11-10 00:02:53 +01:00
Evrim Cicek bdf33dd2a3 Merge remote-tracking branch 'upstream/main' into tests/mob-landing-neighbourhood 2026-11-10 02:01:58 +03:00
MrCloudy2andClaude Fable 5.2 016630efda Keep creatures.rs from growing: the vehicle re-tick lives in wind.rs
The file-size rule (docs/layers.md#file-size). hurt_vehicle moves with it.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:00:38 +01:00
Ludovic Marie 2d610e9482 Merge pull request #14140 from MSedgeMC/fix/piglin-guarded-anger
🤖 Agent: ANGRY_AT's UUID and long TTL load back (clamped, not wrapped, past i32), and the test loads a whole saved piglin; the new head only merges the test-fix commit the branch already held (same tree as da800dff).
2026-11-09 23:57:55 +01:00
Ludovic Marie e6eccbd3b9 Merge pull request #13920 from MSedgeMC/port_map/item-activation-file
🤖 Agent: confirmed on main: tick_item_activation is at game/totem.rs:175, called from game.rs:6092 and remote.rs:6613.
2026-11-09 23:57:51 +01:00
MrCloudy2andClaude Fable 5.2 3a953f4f9a A geyser launches projectiles, boats and minecarts
PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER pushes every entity in the
column; only mobs, items and primed TNT were.

Harness: tools/test_geyser_launch_parity.py.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 23:55:52 +01:00
Horizon agentandClaude Opus 5.5 91b743b549 Telemetry and mod registry images copy every crate their lockfile builds
Railway's builds of both services fail since horizon-telemetry gained horizon-addresses (and the
platform crates) and horizon-mods gained horizon-release-sign: the Dockerfiles copy a fixed list of
crates, so cargo can't find the new path dependencies. The old deployments keep running, so nothing
looked broken. Copy every path crate of each service's own Cargo.lock, watch them in railway.json,
and test both lists against the lockfiles so the next new dependency fails verify instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:54:45 +01:00
Evrim CicekandClaude Opus 5.5 59923087d5 mob_landing_support_tests load the 5x5 chunks entities need to tick
Fixes #14493: since #13953 an entity ticks only once every chunk within 2
columns is loaded; with only chunk (0, 0) the pig never fell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:51:39 +03:00
MSedgeMC 763b6f6c8c port_map: map WitherBoss side-head target sight 2026-11-09 22:49:37 +00:00
MSedgeMC 88c287eb7e test: a wither's side head does not pick through a wall 2026-11-09 22:49:37 +00:00
MSedgeMC 94f499bdbe fix: wither side heads need sight of a target 2026-11-09 22:49:37 +00:00
MSedgeMC f943e82857 port_map: map EvokerWololoSpellGoal.canUse visibility range 2026-11-09 22:49:30 +00:00
MSedgeMC 6efab1f890 test: an invisible blue sheep is not wololoed from five blocks 2026-11-09 22:49:30 +00:00
MSedgeMC 34b5ab04b0 fix: scale the evoker's wololo range by visibility 2026-11-09 22:49:30 +00:00
MSedgeMC a76ea00dde port_map: map SulfurCubeTemptGoal.canUse visibility range 2026-11-09 22:49:22 +00:00
MSedgeMC d7c7e06634 test: an invisible player tempts a sulfur cube only within two blocks 2026-11-09 22:49:22 +00:00
MSedgeMC 7f8a855d78 fix: scale a sulfur cube's tempt range by visibility 2026-11-09 22:49:22 +00:00
Ludovic Marie 537539fe58 Merge pull request #14291 from horizon-rust/split/cosmetics-fx
🤖 Agent: step 2b-iii of spec #14091 moves companions, trails and auras onto the hooks; stacked on #14282.
2026-11-09 23:37:32 +01:00
Ludovic Marie cbbd362eed Merge pull request #14011 from v0ntr3n/fix/stand-command-hurt-events-freeze-immunity
🤖 Agent: the explosion/ignite/burn branches now attribute their game events to the cause in place and ignition clears TicksFrozen; damage_immunity checks the stand's active slots first. Reworked so no dead copy remains; 15/15 stand tests pass with resources.
2026-11-09 23:37:28 +01:00
Ludovic Marie 1430b9990a Merge pull request #14381 from horizon-rust/split/discord-setting
🤖 Agent: the Discord switch keeps its discordPresence line in horizon-extras.txt through horizon-discord's settings store, so players keep their choice while the row moves to Extras > Multiplayer; e2e 4/4 and verify green on this head.
2026-11-09 23:37:24 +01:00
Evrim Cicek 5f68b5da25 Merge remote-tracking branch 'upstream/main' into fix/landing-vibrations 2026-11-10 01:36:24 +03:00
Horizon agent 64a6b82dd0 Merge controller/dial-3-play (radial colours from menu_style) into controller/dial-4-editor 2026-11-09 23:34:37 +01:00
Ludovic Marie a84f637dd6 Merge pull request #14282 from horizon-rust/split/cosmetics-fx-api
🤖 Agent: step 2b-ii of spec #14091 gives PlayerDraw motion, view, looks, particles and first_person so the cosmetics need nothing else from the engine; stacked on #14274.
2026-11-09 23:32:09 +01:00
Horizon agentandClaude Opus 5.5 f2d50d4a75 Radial menu: its colours from menu_style, its curves written out
#13958 (with #14027) took horizon-theme out of horizon-client-render: the
dial's dark page and muted text now come from menu_style's overlay colours,
and OverlayColours gains lit and lit_edge for its sector, which Horizon's
look fills with the night accent and its bright shade, so the dial looks as
before. The easing curves are written as cubic-bezier points like hud_kit's,
and a test pins them to horizon_theme's QUICK and MOVE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:29:07 +01:00
MrCloudy2andClaude Fable 5.2 ca01155bce A wind charge passes a vehicle an earlier charge of the tick broke
Vanilla ticks entities one after the other, so a charge never meets a boat or
minecart that an earlier charge already broke. The tick's hits are all found
first here, so such a charge ticks again without the broken vehicle.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 23:28:23 +01:00
Evrim CicekandClaude Opus 5.5 2740471e60 A falling block a piston pushes keeps Entity.move's fall bookkeeping
A piston's push is Entity.move(MoverType.PISTON): the drop adds to the
falling block's fall, and landing runs fallOn and posts HIT_GROUND in
that step. Review finding on #14440.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:28:02 +03:00
3f63aa1f5c Run a waxed wall hanging sign's click command instead of chaining a sign
WallHangingSignBlock.shouldTryToChainAnotherHangingSign lets the hanging sign
in hand chain onto the sign only when the side the player faces cannot execute
its click commands (SignBlockEntity.canExecuteClickCommands), so a waxed sign
with a run_command hands the click to SignBlock.useWithoutItem, which runs it
and returns SUCCESS_SERVER.

The client now answers that for the wall case as it already did for the
ceiling one (chains_hanging_sign: isHittingEditableSide by the sign's FACING
axis), so the click runs the command, plays the waxed-sign failure and places
nothing; the world sim's unconditional Pass for the wall edge is gone.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 23:26:38 +01:00
Ludovic Marie efab4d1bbf Merge pull request #14274 from horizon-rust/split/cosmetics-own
🤖 Agent: step 2b-i of spec #14091 draws the player's own body and the inventory picture through PlayerDraw; stacked on #14265.
2026-11-09 23:23:44 +01:00
Solenopsisbot a151569d72 Merge remote-tracking branch 'origin/main' into t3/fix-high-contrast-pack-hang 2026-11-10 09:23:34 +11:00
Vinícius MoreiraandClaude Opus 5.5 bad4b0ef9a A horse draws its golden armour
The horse armour layer took its texture from the item id
(golden_horse_armor -> horse_body/golden), but the golden armour's
equippable asset_id is minecraft:gold, whose horse_body texture is
entity/equipment/horse_body/gold. The layer found no texture and drew
nothing. horse_armor_asset maps each horse armour to its equipment
asset; a test checks every vanilla horse armour against items.json and
equipment/*.json.

Fixes #13826

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:28 -03:00
Horizon agent 604fb90bf1 Merge controller/dial-3-play (with origin/main) into controller/dial-4-editor 2026-11-09 23:21:18 +01:00
Horizon agent b2d72cea9e Merge origin/main into controller/dial-3-play (graphics_style colours are functions now) 2026-11-09 23:20:59 +01:00
Ludovic Marie 7b50bae250 Merge pull request #13958 from horizon-rust/layers/window-icon-hook
🤖 Agent: the engine takes the window icon from the layer above, and entry.rs keeps main's pack_formats::install() after horizon_look::give(), so the icon is still given before any window opens. The head now also carries the menu_style stack (#14026, #14027, #14029): graphics_style's and ui_theme's colours are one OnceLock read per call from menu_style, Horizon's BLOCKS_DARK values come from horizon_look.rs at startup so the menus look the same, and the renderer and client lib no longer depend on horizon-theme. Merges cleanly with main.
2026-11-09 23:19:07 +01:00
Ludovic Marie 3780c215a7 Merge pull request #14265 from horizon-rust/split/cosmetics-layers
🤖 Agent: step 2a-ii of spec #14091 draws the cosmetic items on the body through PlayerDraw instead of calling the cosmetics from the engine; stacked on #14214.
2026-11-09 23:19:02 +01:00
SolenopsisbotandCodex 3a22ba32c2 Fix texture metadata parsing during pack startup
Co-Authored-By: Codex <[email protected]>
2026-11-10 09:17:35 +11:00
0xRinne 20d37726a2 Merge branch 'main' into up12/cem-legacy-textures 2026-11-09 16:16:51 -06:00
0xRinne a95c4653f4 Merge branch 'main' into up12/cem-this 2026-11-09 16:16:48 -06:00
5f61de11d9 Parallel tick: mob entity data and attributes from plain tables, on helpers
mob_meta reads SyncData instead of the hub (SyncData::effect_meta, with main's
shown_color; Hub::effect_meta calls it), and the attribute instances read
AttrSources (the catalog, the items, /attribute's maps) instead of Game. While
the parallel tick is on, the mobs' entity data and attributes are built after
the pass on the pool and put in place (game/lan/mob_states.rs); with it off
they are filled in place as before, with no new Vecs. What is read in order
(idle state, lead, equipment network ids) stays in the pass. A test checks the
states put in place equal those built in place.

From kacpherek's #13644.

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

From kacpherek's #13644.

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

From kacpherek's #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:14:03 +00:00
Ludovic Marie 3c853b46eb Merge pull request #14155 from horizon-rust/split/discord-crate
🤖 Agent: the presence now starts only from horizon-official's client_started hook, so horizon-client's own executable (plain builds, harnesses) never opens Discord's socket; the e2e passes 4/4 on the official binary and verify is green on this head.
2026-11-09 23:13:59 +01:00
2d79ae6ace Armadillo: a baby follows only adults its sensor's box meets
NearestLivingEntitySensor.doTick finds the living entities whose boxes meet the
baby's box inflated by its FOLLOW_RANGE (16). BabyFollowAdult took every adult
others_near returned, which only narrows to grid cells and, when the grid falls
back to a scan, returns every mob: the baby checked line of sight to, and could
follow, adults anywhere in the level. Found by kacpherek's verify mode in #13644.

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:13:48 +00:00
MrCloudy2andClaude Fable 5.2 eedbb1717c A wind charge's hit damages a boat or a minecart
AbstractWindCharge.onHitEntity hurts what it hit by one point before the
burst; a vehicle takes it through VehicleEntity.hurtServer and breaks on the
fifth hit of a tick. wind_charge_damage only looked among the mobs.

Harness: tools/test_wind_charge_vehicle_parity.py.

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

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

Fixes #14450

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:08:44 +01:00
Evrim CicekandClaude Opus 5.5 fad8e4e555 Primed TNT and falling blocks post hit_ground when they land
Part of #14440: PrimedTnt and FallingBlockEntity move with Entity.move and
keep its checkFallDamage, which posts HIT_GROUND after the block's fallOn
when they land after a fall (a piston's push too, for TNT). Extends the
`entity landing sensors` command-exec group.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 01:06:08 +03:00
Horizon agent dbbe0e6bf5 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 22:59:17 +01:00
Ludovic Marie e64af82d00 Merge pull request #13771 from MSedgeMC/fix/lectern-has-book-signal
🤖 Agent: matches LecternBlock.getAnalogOutputSignal (HAS_BOOK checked before the block entity, 26.3 bytecode); I pushed 1d52dd4: the new test had taken the next test's doc comment, and it now also checks has_book=true reads 15.
2026-11-09 22:53:29 +01:00
5a78eee6ca Items spilled from a destroyed shulker box or bundle pop upward again
`ItemUtils.onContainerDestroyed` makes each stack a destroyed item held
with `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
0.2 less 0.1 sideways from the *item's* own random
(ItemEntity.java:61-66). The spill drew three doubles from the level
random instead, so the stacks barely rose and the level's stream
advanced three times per stack. Route it through `spawn_item_own`,
which already draws the four-argument constructor's motion (#13938).

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 22:49:34 +01:00
Nikita Maximilian GrimmandClaude Fable 5.2 0f5b71e021 Merge remote-tracking branch 'origin/main' into prepared-data-stale-tests
piston_push.rs: main's version (#13655 made the same correction to the order test's expected push).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 22:48:56 +01:00
Ludovic Marie 37bf47f477 Merge pull request #13655 from TheApple05/fix/12091-piston-slime-bounce
🤖 Agent: mob piston pushes now restitute after the move, reading the landing block as Entity.move does, so a pushed mob bounces 1.0 on slime; the corpus case puts the player back so the command_exec oracle runs through (only a known #13022 boat tick differs vs main); run-parity covers the TNT commit.
2026-11-09 22:48:00 +01:00
Horizon agentandClaude Opus 5.5 e11233f2dc The title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
Join a Friend opens from vanilla's Friends icon slot, unchanged in place
and size at every GUI scale; it is now drawn inactive, and takes no click,
key or controller press, when no Hooks::tunnel resolver is registered
(net::joins_friends), as the pause menu's icon is without a sharing page.
horizon-official registers horizon-friends' resolver, so players of the
official game see no change; the plain executable, which refuses every
friend: address, no longer opens a screen that cannot join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:45:31 +01:00
J5CaseyandClaude Opus 5.5 941e45b1ba Recipe book: number a server's synced recipes as the server does (#14435)
A client sent the server's recipes (horizon:recipe_content) numbered its
recipe book displays from its own files, while the server numbers them over
its recipe set, a world's data pack recipes included. Each pack recipe moved
the server's numbers past the client's, and the server's book entries, put
over the book by number, replaced other recipes with server:N entries.

The display numbers now come from the synced recipe set itself
(RecipeIndex::load_from_sources), for the book, its toasts and the
RecipeBookAdd toasts; without synced recipes nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-09 15:45:03 -06:00
voduffandClaude Opus 5.5 916064d1ac A sulfur cube's body takes a swallowable block through /item replace
SulfurCube.getEquipmentSlotForItem answers BODY for every
#sulfur_cube_swallowable item, so the body's SlotAccess takes it. Horizon
checked only the item's own equippable slot and refused every block.

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:39:48 +01:00
MSedgeMC 4ef09a8e0d port_map: the written book resolution rows are ports
`resolveForItem`, `resolve`, `markResolved`, `resolvePage` and
`isPageTooLarge` cite `WrittenBook::resolve` and `resolve_held_book`.
The spawn egg rows cited `Game::use_spawn_egg` in `creatures.rs`, where
it no longer is: the function lives in `creatures/spawn_eggs.rs`.
2026-11-09 21:39:30 +00:00
Ludovic Marie 03d8150bdb Merge pull request #13867 from horizon-rust/perf/villager-poi-scan
🤖 Agent: villager POI searches now take the PoiSections lock and column lookup once per chunk instead of once per section; on the 122-player SMP bench (190 villagers, 3 rotated rounds) villagers went from 20.3-21.3 to 15.7-15.9 µs/mob (ratio to chicken/cow/sheep 2.0-2.1 -> 1.75), ~0.5 ms/tick normalized; same POIs in the same order (20k-step differential test vs the old per-section get loop).
2026-11-09 22:39:30 +01:00
MSedgeMC d9cf8f497c test: a written book's pages resolve and keep their resolved flag
The resolve test rewrites a selector page and a score page (a plain page
is left alone), marks the book resolved and then leaves a resolved book
alone; the over-long test keeps the raw pages past 32767 characters and
still marks the book resolved; the codec test round-trips `resolved`.
2026-11-09 21:39:30 +00:00
MSedgeMC a321aa6c73 fix: a written book resolves its selectors and scores when it is opened
`WrittenBookContent.resolveForItem` runs in `ServerPlayer.openItemGui`:
`resolve` replaces every page's selectors and scores with the text they
name and marks the book resolved, and a page whose resolved text passes
`resolvePage`'s 32767 characters cancels the resolution (the book is only
marked resolved).

Horizon kept the page components (`WrittenBook.page_tags`) but never
resolved them and had no resolved flag, so a page holding a selector
showed its raw component text and a book could not be told as resolved.

`WrittenBook` gains `resolved` (read and written by the component codec),
`WrittenBook::resolve` ports the resolution, and `Game::resolve_held_book`
resolves the book in the acting hand when `use_book` opens it (a LAN
guest's copy is the mirrored inventory, so its slot is sent back).
`set_written_book_pages` was already applied in `loot.rs`.
2026-11-09 21:39:30 +00:00
Ludovic Marie c89cd3f9e1 Merge pull request #14374 from horizon-rust/docs/governance
Add GOVERNANCE.md: the council, theme groups and how roles are earned
2026-11-09 22:35:02 +01:00
Ludovic Marie 0b8d44458a Merge pull request #14026 from horizon-rust/layers/render-style
🤖 Agent: graphics_style's and ui_theme's colours are now one OnceLock read per call from menu_style; Horizon's BLOCKS_DARK values come from horizon_look.rs at startup, so the menus look the same; the renderer and client lib no longer depend on horizon-theme.
2026-11-09 22:32:43 +01:00
Ludovic Marie 75a016b1b0 Merge pull request #14211 from oloion/fix/frog-walk-animation
🤖 Agent: the client now runs Frog.updateWalkAnimation (min(d*25,1), 0 while jumping); FROG_WALK keeps FrogModel's 1.5 factor (agent fix 341a929).
2026-11-09 22:32:39 +01:00
Vinícius MoreiraandClaude Opus 5.5 2490c57d3e Polytone: the drop happens when the particle joins, not at its first tick
Dropping a particle with physics at its first tick still drew it for one
frame, so a falling leaf flickered with the newest wide copy through it.
The check now runs when a pending particle joins the engine list, and
set_polytone moves unchanged to particles/polytone.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:32:03 -03:00
voidsip e624d71b21 Remove a needless borrow in the arrow fixture 2026-11-09 15:31:36 -06:00
Horizon agentandClaude Opus 5.5 a190dbbc0d Crates ship their own lang files: horizon_hooks::lang (layer plan M5)
horizon_hooks::lang::register(&'static [(code, json)]), re-exported as
horizon_platform::lang. The game's entry installs the registered files
into horizon_client::lang; Language::load reads them in the horizon
namespace right after the engine's bundled file for the same language,
before packs, and the engine's file wins on a key both have. Regional
variants share one alias table with the bundled files (fr_ca -> fr_fr,
de_ch -> de_de without ß). Nothing registered: languages load as before.

tools/lang_coverage.py reports crates/*/assets/lang beside the game's
folder; a crate's review record is tools/lang_review/<crate>/, and
--mark-reviewed puts each key in the folder that holds it. lang.rs's
file checks run on every crate folder, and a crate key the engine also
has fails them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:30:16 +01:00
Horizon agentandClaude Opus 5.5 c21f627431 discord_presence_e2e: scroll the Extras page to the switch
Only the rows on screen are widgets; the Multiplayer group is below the fold
of an offscreen window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:26:36 +01:00
Ludovic Marie 691a39cab6 Merge pull request #14204 from horizon-rust/layers/studio-extras-gate
🤖 Agent: studio::EDITOR is a compile-time constant (true by default, so the default build runs no check); without horizon-extras recording/playback stay and the editor input, drawing and commands are off.
2026-11-09 22:25:53 +01:00
DUzzL 22bfb7d055 Sweep a cart's blocks right after its move, like AbstractMinecart.move
OldMinecartBehavior's AbstractMinecart.move calls
applyEffectsFromBlocks(position(), position()) straight after the move,
while the cart is still at the rail's top height, and again at the end of
the tick. The port swept once at the settled position, so a cart held by a
fence on a slope never reached the pressure plate above it again.
2026-11-09 22:23:28 +01:00
Ludovic Marie 3c06105a0e Merge pull request #14199 from horizon-rust/layers/replay-move
🤖 Agent: pure move checked by line multiset; the editor is reached through five calls (save path, replay opened/advanced/closing/closed) at the places its code was.
2026-11-09 22:20:16 +01:00
YourPancakes f779b500ec perf(mobs): skip redundant swim block reads 2026-11-10 00:16:52 +03:00
Ludovic Marie 726726a1fc Merge pull request #14186 from horizon-rust/layers/replay-state
🤖 Agent: playback state (driver, camera type to restore, standalone, playback clock) moves to game/replay.rs Replay; Studio keeps the editor; callers ask replay.playing(); game.rs does not grow.
2026-11-09 22:14:22 +01:00
Ludovic Marie 82da4379bd Merge pull request #14161 from horizon-rust/layers/studio-calls
🤖 Agent: pure move: game.rs's inline Studio blocks and the replay key shortcuts become game/studio.rs methods with the same bodies and early returns; the engine asks replaying()/flying()/active().
2026-11-09 22:09:29 +01:00
Ludovic Marie 7627e37dba Merge pull request #14351 from horizon-rust/split/pages-plain-rows
🤖 Agent: Row::Button/Text are #[non_exhaustive] variants built through constructors, so later options do not break crates; the options shell pairs two half buttons into one row and a press on an inactive one reaches nothing (tested).
2026-11-09 22:09:25 +01:00
YourPancakes 4ed92f7176 perf(mobs): reuse item cells for villager sensing 2026-11-10 00:06:46 +03:00
MSedgeMC 6e8edf7ad2 port_map: ShulkerBoxBlockEntity.doNeighborUpdates is a port
The row said the four lid-step calls were counted and dropped;
`Behaviours::shulker_neighbor_updates` now runs them, and `tick_shulkers`
calls it once per counted update.
2026-11-09 21:04:49 +00:00
MSedgeMC deb5860d9e test: a shulker lid reaches all six neighbours
`a_shulker_lid_updates_the_shapes_and_redstone_of_its_neighbours`
observes the level as `shulker_neighbor_updates` runs: each of the six
neighbours gets one `neighborChanged` from the shulker's block, with its
own state as the changed state. Without the call the level sees none.
2026-11-09 21:04:49 +00:00
MSedgeMC 6dc2d97de4 fix: a shulker box lid updates its neighbours as it moves
`ShulkerBoxBlockEntity.tick` calls `doNeighborUpdates` when its lid
starts or finishes opening or closing: the box's collision shape moved,
so the neighbours' shapes are updated (flag 3) and their
`neighborChanged` runs. Horizon's render tick counted those four calls
(`ShulkerTick.neighbor_updates`) and the game dropped them, so redstone
beside a moving shulker box never saw the update.

`Behaviours::shulker_neighbor_updates` ports the two calls, and
`tick_shulkers` runs it once per counted update. The box's collision
shape for the same ticks already reaches the world through
`shulker_collision_state`.
2026-11-09 21:04:49 +00:00
Ludovic Marie cd8eb96cef Merge pull request #14214 from horizon-rust/split/cosmetics-draw
🤖 Agent: step 2a-i of spec #14091 adds PlayerDraw to horizon-hooks (plain types only) so crates can draw on player models in the world pass; creatures.rs is main's, with the swap moved to creatures/mob_geometry.rs.
2026-11-09 22:04:26 +01:00
Ludovic Marie 829aec1dba Merge pull request #14080 from rdbtCVS/screenshot-off-render-thread
🤖 Agent: F2 and world-icon PNGs are written on a thread after map_async; a second F2 in the same second now skips the name still being written (agent fix 1ffa9ed), and agent screenshots keep the synchronous path.
2026-11-09 22:04:22 +01:00
Ludovic Marie 82a6e896d1 Merge pull request #13627 from vnsmoreira/fix/armor-stand-player-break-loot
🤖 Agent: the player break now pops the named stand item and then goes through brokenByAnything, which plays the break sound before the loot as ArmorStand.brokenByAnything orders it; stand_break_loot, stand_command_hurt and armor_stand_equip_events pass with it, and command_exec on a union with main shows no armor stand mismatch.
2026-11-09 22:04:18 +01:00
Horizon agentandClaude Opus 5.5 62e62c21e6 horizon-discord: the switch is the first splitscreen player's (seat)
hooks::seat() is on main now: other players' settings neither load nor
save the line, and their Extras page has no row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:53 +01:00
Horizon agent a6350aa8f9 Merge branch 'split/discord-crate' into split/discord-setting
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 22:02:16 +01:00
Horizon agent c817ed7fff Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 22:02:02 +01:00
JoansitohandClaude Sonnet 5.5 80687d32d1 Launcher: the Home version picker scrolls its list
The Version menu capped its height to the room under the button and its
rows scroll (with the launcher's scrollbar and take_wheel); Manage
versions stays pinned below them.

Fixes #14407

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:59:51 +01:00
Horizon agent b020ab26b9 piston mob bounce: put the player back when the case ends
The case left the player at 1.5 200 1.5; with that, vanilla's command_exec oracle did not load the chunks of the later teleport lands group (required chunks did not load, twice). Every other group that moves the player puts it back at 8.5 230 8.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 277584e4df6f0b090655d50d19138c89dad4b77a)
2026-11-09 21:59:36 +01:00
DUzzL 82d1afa019 Merge remote-tracking branch 'origin/fix/14297-hopper-decorated-pot' into fix/14297-hopper-decorated-pot 2026-11-09 21:59:29 +01:00
DUzzL c7c8dd8f82 Merge upstream/main into fix/14297-hopper-decorated-pot 2026-11-09 21:59:16 +01:00
Ludovic Marie 0300ba0a76 Merge pull request #14339 from horizon-rust/team/drop-itarqos5
🤖 Agent: removes itarqos5 from .github/trusted-reviewers.txt, the Team and reviewer lists in docs/release-flow.md and the pinned login in tests/test_nightly_set.py; the nightly, claim and triage test modules (102 tests) passed per the PR.
2026-11-09 21:59:04 +01:00
Ludovic Marie 3a3c182aed Merge pull request #14208 from horizon-rust/split/cosmetics-names
🤖 Agent: step 3b makes names.rs, scoreboard.rs and perspective.rs call the engine's dispatch (Game::hooks_name_marks builds one PlayerModel per tag drawn) instead of the cosmetics directly.
2026-11-09 21:56:53 +01:00
AppaandCodex 35f095bd41 Fix TNT restitution after piston landings
Read the actual landing block for authoritative TNT after applying the
piston push. Preserve client fallback ordering and exhausted-budget
returns. Validate five exact vanilla landings and server/client controls.
Correct the neighboring order test's rounded TNT-width expectation using
the pinned vanilla result without widening its tolerance.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 12:55:45 -08:00
rdbtCVSandClaude Sonnet 5.5 74a38d29aa Keep ai.rs the same size: inline the one-use creature local
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:53:49 +00:00
Ludovic Marie e76394c2cf Merge pull request #14107 from horizon-rust/split/official-servers-crate
🤖 Agent: the official server now reaches the list only through horizon-official's started hook, so horizon-client's own executable (plain builds, harnesses) has neither the official server nor the withdrawn partner's address; verify and launcher green on this head.
2026-11-09 21:53:06 +01:00
Ludovic Marie d9cfca426c Merge pull request #14207 from horizon-rust/split/cosmetics-sprites
🤖 Agent: the API half of cosmetics step 3: Hooks::name_decoration returns a Copy NameDecoration (fixed &'static mark rectangles, optional badge, tag lift) so no heap data or engine type crosses the hook.
2026-11-09 21:53:02 +01:00
rdbtCVSandClaude Sonnet 5.5 e10e2144bd Merge main into fix/14297-hopper-decorated-pot
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 20:49:39 +00:00
DUzzL 920e317f53 Make jmath::pow return NaN like Java for NaN exponent and |x|==1 with infinite y 2026-11-09 21:47:25 +01:00
Vinícius MoreiraandClaude Opus 5.5 9286b28e24 Polytone: a particle with physics born inside a block goes at once
A custom particle with has_physics whose box starts inside a block's
collision is dropped before its first tick, so its emitters never run.
Falling leaves start 0.05 under their leaf block, and a pack's leaf with a
0.08 hitbox reaches into it: in Java such a leaf never falls, while here
every one fell, and a leaf that emits a resting copy of itself each tick
left trails of them under every tree until the frame rate collapsed.

Compared in game against Java 26.3 with Polytone, with particles of its
own: a box overlapping a block by 0.01 goes, 0.01 clear of it lives, and
a particle without physics lives either way; the emitters do run every
tick. A test covers the three cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:54 -03:00
CxandClaude b13153310c chat: fix touchpad scrolling in command suggestions
Round the suggestion list scroll offset instead of truncating f64->i32.
Fractional touchpad/PixelDelta wheel deltas were dropped in one
direction, so command hints scrolled down but not up.

Fixes #14338

Co-Authored-By: Claude <[email protected]>
2026-11-09 23:45:47 +03:00
MSedgeMC 3a642c76c0 port_map: AbstractSelectionList.scrollBarX 2026-11-09 20:45:37 +00:00
MSedgeMC 79551ef835 test: the social list's scrollbar sits right of the rows
Its column, from getRowRight + scrollbarWidth + 2 at two window widths, and
the overflow rule that decides whether it shows.
2026-11-09 20:45:37 +00:00
MSedgeMC d4a019ad08 fix: the social player list shows its scrollbar
AbstractSelectionList.extractRenderState draws its scrollbar at scrollBarX
(getRowRight + scrollbarWidth + 2) whenever the rows overflow. Horizon clipped
and scrolled the rows but drew no bar, so a long list gave no sign of its
position.
2026-11-09 20:45:37 +00:00
MSedgeMC a794f852d7 port_map: CreativeModeInventoryScreen.checkTabHovering is ported 2026-11-09 20:45:23 +00:00
MSedgeMC ff65ebc7fc test: only a tab's inset icon is hovered
The cursor on the 3 pixel border of a tab's slot is outside checkTabHovering's
21x27 icon rectangle; inside it the tab is the hovered one.
2026-11-09 20:45:23 +00:00
MSedgeMC 6767556a09 fix: the creative inventory's tabs say their name on hover
CreativeModeInventoryScreen.checkTabHovering shows a tab's display name when
the cursor is on its icon (3 pixels in from the 26x32 slot). Horizon drew the
tabs without the tooltip, so a tab's name only appeared in a search result's
tooltip.
2026-11-09 20:45:23 +00:00
Ludovic Marie 763d4868d7 Merge pull request #14181 from horizon-rust/split/cosmetics-hooks
🤖 Agent: cosmetics step 1 of spec #14091: the emote feed and presence go through plain-type frame and tick hooks in horizon-hooks, so no engine or renderer type crosses the seam.
2026-11-09 21:44:17 +01:00
voidsip df4b2599b4 Load the portal fixture's entity-ticking neighborhood 2026-11-09 14:43:57 -06:00
valuttaandClaude Fable 5.2 0a079cdd75 Server tick: boats and minecarts read the mobs near them, not every mob
Each vehicle walked every mob of the level several times a tick:

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

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

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 21:40:42 +01:00
Riccardo Guerriero 8aea9cb52b Merge remote-tracking branch 'origin/main' into fix/frame-vibrations 2026-11-09 21:40:02 +01:00
YourPancakes fd70c6aa11 perf(render): reuse native sprite frames when sizes match 2026-11-09 23:36:19 +03:00
Ludovic Marie 110a92a1aa Merge pull request #14096 from horizon-rust/layers/seat
🤖 Agent: seat() is a thread-local the engine sets at Split::turn and around a guest's join/profile load (the profile used to load before the guest's turn, into P1's seat); a new profile saves on P1's seat and reads its stores back into the guest's. Without splitscreen it is always 0. Main merged in (horizon_platform::hooks exports both seat and main's tunnel).
2026-11-09 21:35:51 +01:00
c451a0eca5 Streaming: the forced chunks' code in a module of its own, with a test
ForcedCover and the Ranges methods that read the forced chunks (is_forced,
forced_chunks, and in_main_range past the player's circle) go to
streaming/forced_cover.rs, so streaming.rs doesn't grow. A test checks that the
distance map answers every margin as the scan of every forced chunk does, as the
forced chunks change, and that past MOST no map is made.

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

Co-Authored-By: Kacper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:31:14 +00:00
Ludovic Marie 3a8d647c8c Merge pull request #14154 from Skr1zzzz/falling-removals
🤖 Agent: one pass over the big lists per RemoveEntities packet, per-id cleanup order kept (dragon before its crystal); moved to remote_removal.rs so remote.rs shrinks (agent fix 8cf1e6b).
2026-11-09 21:30:12 +01:00
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Ludovic Marie 3c346ba1a4 Merge pull request #13810 from NikitaMGrimm/hdr-lighting-one-meaning
🤖 Agent: re-checked the new head: only main merged in, and the PR's own diff is unchanged. shader_pack_kind().is_none() is still exactly the old native/pack_post test, so HDR Lighting greys only where the running pack lights the world.
2026-11-09 21:25:43 +01:00
Ludovic Marie b41db51bf9 Merge pull request #13808 from vnsmoreira/packs/polytone-fluid-fog
🤖 Agent: re-checked the new head: the only change is that FluidFog/FluidFogs moved to the render crate's pack_palette after #13959, converted from horizon_polytone's type once when the pack loads. The fog still runs last and is vanilla's without a modifier.
2026-11-09 21:25:38 +01:00
AkirabaneandClaude Sonnet 5.5 722f39f691 Skin loader: bound its downloads, states and resources
`Downloader` kept an entry in `states` for every texture URL asked for and
started a thread (with its own HTTP agent) for each, and an entry in
`resources` for every `texture` id of a profile, none ever dropped. A tab list
of 200 players started about 400 threads at once.

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

Part of #9370.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:23:04 +01:00
Horizon agentandClaude Opus 5.5 498d9bebcf Add GOVERNANCE.md: the council, theme groups and how roles are earned
The governance the founder adopted on 2026-11-09 as his last decision as
head of the project: a council of equals deciding by 24-hour polls, theme
groups with leads, the selection filter, moderation and appeals, the 18+
rule for roles of trust, the agents, and the account handover path.
README and CONTRIBUTING link to it with one line each.

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:33 +01:00
Ludovic Marie 1ecc5a9bdc Merge pull request #13811 from Mik00000/perf/natural-spawning-cache
🤖 Agent: Util.shuffle runs every pass again, so the level random keeps vanilla's draws, and nothing is cached across ticks any more. What stays saves allocations: Counted streamed instead of collected, the SpawnPlayer buffer reused, and a single-player collect without the neighbour scan. Fake players stay out of the anchors and in the level, as on main. Merged with main on a branch together with #13795 and #13800: clippy and 3827 tests pass; natural_spawn runs in CI parity.
2026-11-09 21:21:02 +01:00
Ludovic Marie d05675d755 Merge pull request #13800 from Mik00000/perf/block-entities-opt
🤖 Agent: every hopper again records tickedGameTime each tick, so the 8/7 transfer cooldown matches vanilla, and is_full keeps #13763's insertion_item for pots. The gains stay: slot iterators instead of a Vec per transfer, empty hoppers with nothing above them or within reach skip the whole pass, one block lookup per tick. Merged with main on a branch together with #13795 and #13811: clippy and 3827 tests pass; the hopper harnesses run in CI parity.
2026-11-09 21:20:57 +01:00
Ludovic Marie 2568835cc6 Merge pull request #13795 from TheMrZZ/fix/command-spawnpoint-respawn-config
🤖 Agent: with the offline capture tools removed, the pitch now goes through RespawnConfig, the personal respawn save (old records load pitch +0.0) and only the free-point branch of find_respawn_and_use; bed and anchor keep pitch 0, as RespawnData's block cases do. Merged with main on a branch together with #13800 and #13811: clippy and 3827 Rust tests pass on the desktop.
2026-11-09 21:20:53 +01:00
voidsip 8aaa338f08 Separate Nether portal cooldown handling from gateway changes 2026-11-09 14:20:39 -06:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +01:00
voidsip 79b62aaaea Keep witness visibility inputs on seen villagers 2026-11-09 14:17:44 -06:00
martipopsandClaude Opus 5.5 0341ad3d3a Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:37 -05:00
Detimes 02b3b03618 Merge remote-tracking branch 'upstream/main' into gap-line-of-sight 2026-11-09 20:17:13 +00:00
DetimesandClaude Haiku 5.5 0acd52bcb5 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:11 +00:00
Detimes 02f00f9b83 Merge remote-tracking branch 'upstream/main' into gap-riding-getters 2026-11-09 20:17:08 +00:00
Detimes 7d75faab81 Merge remote-tracking branch 'upstream/main' into gap-server-props-read 2026-11-09 20:17:05 +00:00
Detimes 0eddebf75e Merge remote-tracking branch 'upstream/main' into gap-map-colors-read 2026-11-09 20:17:04 +00:00
DetimesandClaude Haiku 5.5 baab01b031 Work in progress
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 20:17:03 +00:00
Detimes 034c4b6e5c Merge remote-tracking branch 'upstream/main' into gap-mob-death-message 2026-11-09 20:16:59 +00:00
Detimes 566027307e Merge remote-tracking branch 'upstream/main' into gap-entity-lookup 2026-11-09 20:16:57 +00:00
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
voidsip e8dbc42da7 Use the oracle memory constructor entity-list type 2026-11-09 14:13:14 -06:00
martipopsandClaude Opus 5.5 6cc30fbed4 Controllers (radial): main's input_holds asks the pad what each press reached
The radial series' holds_action follows what a press went to, not the binding,
so it takes no settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:12:36 -05:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
voidsip adfc70d2f3 Split custom arrow contents and expiration from potion impacts 2026-11-09 14:10:51 -06:00
Horizon agentandClaude Opus 5.5 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:10:39 +01:00
voidsip 9da6aa25e5 Split cached villager murder witnesses from hurt attribution 2026-11-09 14:10:25 -06:00
Horizon agentandClaude Opus 5.5 5385996153 Merge #14107 (split/official-servers-crate) into split/discord-crate
Stacks the Discord crate on the official-servers crate: horizon-official
registers four parts; horizon-client-lib has no horizon-extras feature left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:08:57 +01:00
Ludovic Marie f0cb1e42c0 Merge pull request #14156 from horizon-rust/layers/studio-gather
🤖 Agent: the verify failure was the two rustc-compiled fixtures (test_compute_fov_hook, test_world_input_capture) still modelling game.studio_freecam/studio_replay; they now carry the Studio struct game.rs reads, and both pass on this head.
2026-11-09 21:06:48 +01:00
voidsip acf9008e49 Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11536-preserve-live
# Conflicts:
#	.github/workflows/ci.yml
2026-11-09 14:06:12 -06:00
voidsip 7e32b7d0da Merge main before splitting villager reputation fixes 2026-11-09 14:06:08 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 c14ab8d543 Merge remote-tracking branch 'origin/main' into frame-data-count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:05:56 +01:00
voidsip cd9441f90e Merge commit '38174db59bfd5bf90027bd727f894b141c9e72dc' into split/11484-review 2026-11-09 14:05:52 -06:00
martipopsandClaude Opus 5.5 83a9845928 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:03:14 -05:00
martipopsandClaude Opus 5.5 3563a7f986 Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Main's input methods (#14001) replace the pad calls: the radial menu closes with
a new player in input_player_replaced (through pad_player_replaced), and
horizon-ctl's key press still closes it beside input_keyboard_used.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:00:55 +01:00
Ludovic Marie 38174db59b Merge pull request #14170 from MSedgeMC/port_map/barrier-pickup-rule
🤖 Agent: Sim::pickup_block already gates a waterlogged barrier on the breaker being creative, as BarrierBlock.pickupBlock does.
2026-11-09 20:59:57 +01:00
martipopsandClaude Opus 5.5 222d449920 A pack card's options button is a pad target
The pack screen is a pointer screen: a pad moves a crosshair, which
glides onto the nearest control and hops between controls with the
D-pad. A card's options button registered no target, so the crosshair
never settled on it and the D-pad couldn't reach it; only aiming the
stick by hand opened Pack Options. It registers one now, clipped to the
list's view like its rows.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:59:16 -05:00
Ludovic Marie 093f5efda8 Merge pull request #14001 from horizon-rust/layers/input-feedback
🤖 Agent: gameplay now asks controllers only through input_move, input_holds, input_look and input_feedback in play_input.rs, which still call the same pad functions, so the pad modules can later answer through a registered input source.
2026-11-09 20:59:12 +01:00
Ludovic Marie b444a6b64f Merge pull request #14171 from MSedgeMC/worldlane/chest-opener-rechecks
🤖 Agent: same rows as #14128 without the stray worldgen change: Sim::tick_block's chest arm is recheckOpen, scheduled every 5 ticks from count_opener.
2026-11-09 20:59:03 +01:00
Ludovic Marie e8d192f2ac Merge pull request #14218 from horizon-rust/layers/containers-spec
🤖 Agent: docs only: adds the Look and container-screen API specs to docs/layers.md under Platform registries, following #14217's look::update.
2026-11-09 20:58:59 +01:00
Ludovic Marie 6ad39aa412 Merge pull request #14127 from horizon-rust/fix/offline-names-dev-only
🤖 Agent: the game now plays only as the selected account (else the first), which must own the game itself (launch_for_game); old accounts.json/settings.json load and drop offline, offline_selected, looks, skins/ and dev_offline on the next save, covered by files_from_launchers_with_offline_names_load_and_lose_them_on_save.
2026-11-09 20:58:55 +01:00
Ludovic Marie 6342ce83b2 Merge pull request #14002 from MrCloudy2/fix/bounced-projectile-tick-order
🤖 Agent: a bounced arrow goes back by join number through JoinList::put_back (unit-tested), so it keeps its EntityTickList place; moved out of creatures.rs, which may not grow.
2026-11-09 20:58:51 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ac296614af /data on an item frame detaches its old map when only the stack's count changes
ItemFrame.readAdditionalSaveData compares the Item stack as read with
ItemStack.matches, count included, and removes the framed map on a
mismatch; only setItem then keeps one item. apply_entity_nbt compared
against item_frame_from_nbt's stack, already cut to one, so a count-only
change kept the old map's marker. It now compares the stack as read.

Fixes #14178

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:58:37 +01:00
Horizon agent f4e5fbdfd0 Merge the drag hook fixture fix into layers/studio-extras-gate 2026-11-09 20:56:43 +01:00
Horizon agent 6d5b520591 Merge the drag hook fixture fix into layers/replay-move 2026-11-09 20:56:41 +01:00
Horizon agent dff332b257 Merge the drag hook fixture fix into layers/replay-state 2026-11-09 20:56:40 +01:00
Horizon agentandClaude Opus 5.5 c1dc47e41f Tests: the drag hook fixture cuts Studio's drag at studio_mouse_dragged
game.rs's CursorMoved arm now asks self.studio_mouse_dragged(b.width)
instead of the inline timeline drag; the fixture leaves that call out of
its slice as it left the inline block.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:53:10 +01:00
MSedgeMC b529106e69 port_map: the test block own signal reads the state mode 2026-11-09 19:51:47 +00:00
MSedgeMC 78bfaf571a test: a test block state mode gates the powered entity 2026-11-09 19:51:47 +00:00
MSedgeMC 30e60a02ec fix: a test block reads the block state mode for its own signal 2026-11-09 19:51:47 +00:00
Ludovic Marie 595421d8ee Merge pull request #14018 from MSedgeMC/fix/stonecutter-result-components
🤖 Agent: a stonecutting result with a components patch goes through SmithingResult's template create, so a pack's custom-named result keeps its name; plain results stay on the cheap path.
2026-11-09 20:51:44 +01:00
voduffandClaude Opus 5.5 7ae38e4441 port map: the chest minecart save rows follow Minecart's to_nbt/from_nbt to minecarts/nbt.rs
Part of #14327

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

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 22:49:03 +03:00
Ludovic Marie 7169886142 Merge pull request #14122 from MSedgeMC/fix/anvil-selection-colour
🤖 Agent: AnvilScreen.init's setInvertHighlightedTextColor(false) on the rename box; moved apart from #14094's EditBox lines so both merge in either order.
2026-11-09 20:48:53 +01:00
DUzzL 51e490f6f9 Let hoppers pull items out of a decorated pot 2026-11-09 20:48:36 +01:00
Vinícius MoreiraandClaude Opus 5.5 cc900e6c6e Polytone: r.noise is 1.21's PerlinSimplexNoise, seed 0, octave 1
r.noise read a single simplex noise of seed 0, which is octave 0 of
Polytone's noise: the mod builds 1.18-1.21's PerlinSimplexNoise (gone
from 26.3) with octaves {1}, whose noise comes from a random seeded by
the first noise's value at its own offsets, read at twice the input with
the noise offsets. So grass and leaves a pack tints with the noise took
other shades than on Java.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:48:00 -03:00
Horizon agentandClaude Opus 5.5 a8bf4c1474 Team: itarqos5 leaves the Horizon Team
Drops the trusted-reviewers line, the Team lists in docs/release-flow.md and the pinned login in
test_nightly_set. GitHub team memberships (horizon-team, contributors) are already removed.

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

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:45:23 +00:00
Ludovic Marie cdcac3463e Merge pull request #14123 from Mik00000/perf/async-audio-raycasts
🤖 Agent: bee sounds no longer rescan every mob per bee each tick and the sound engine takes one lock per channel update; silent one-frame/empty sounds stop again (agent fix 23ca4a8).
2026-11-09 20:45:15 +01:00
Ludovic Marie e71c626484 Merge pull request #14097 from vnsmoreira/packs/polytone-expr-fast
🤖 Agent: the fast path only takes one-expression programs whose kind is known and is checked bit for bit against the interpreter; 468 lines after the timing test moved to #14185. Queue AFTER 14089.
2026-11-09 20:45:10 +01:00
AkirabaneandClaude Opus 5.5 a05066c560 Cosmetics: the own model on an offline-mode server is asked by the signed-in account
Fixes #12820, the part kept open there: the player's own model. An
offline-mode server names the player by nameUUIDFromBytes of their name (a
version 3 UUID), which the cosmetics service doesn't know, so their own
cosmetics, name tag style and Horizon's mark were missing. own_uuid now asks
by the signed-in account's UUID for such a profile, as In single player does
for the window's own server; an online profile is kept, and signed out the
profile is kept as before.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:45:06 +01:00
Ludovic Marie 194ede9376 Merge pull request #14077 from DUzzL/fix/13935-iron-golem
🤖 Agent: OfferFlowerGoal's (6,2,6) box plus forNonCombat().range(6) with sight, isBrightOutside's fixed-time check, the golem's own random for the repair pitch; the defence sight test the body named is now in the diff (agent fix 4643ea9).
2026-11-09 20:45:06 +01:00
DUzzL 59b4f00696 Share the host's push-and-hurt path 2026-11-09 20:44:59 +01:00
voduffandClaude Opus 5.5 8e8f6ff6fd port map: rows follow fns split out of remote.rs, game.rs, potions.rs, creatures.rs and entity_nbt.rs
Thirteen rows named a fn at the file it was split out of (poll_remote,
tick_world_icon, potion_cloud_to_nbt, the spawn egg fns, the hanging
and trident loaders), so map-check reported "no fn". They now name the
file that holds each fn.

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:42:13 +00:00
Vinícius MoreiraandClaude Opus 5.5 29ac1e17b9 Polytone: fluid modifiers' fog
A fluid modifier's fog_colormap (a flat colour), fog_radius and fog_fade apply to the
water's or lava's fog after everything else: the colour replaces it, the radius scales the
fog, and a fade of 0 makes it a hard wall. Measured on the real mod (red fog at 0.3 of the
radius, within the scene's variance of the vanilla baseline). Colormap ids and expressions
aren't read yet.

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

Part of #14327

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:39:25 +00:00
yahya-nusseibehandClaude Opus 5.5 5328ec28b5 Mod registry: bundle SQLite 3.54 through rusqlite 0.41
The registry's own workspace still resolved rusqlite 0.37 ->
libsqlite3-sys 0.35, which bundles a SQLite older than 3.54
(RUSTSEC-2026-0459: uninitialised page-cache memory from a crafted
database opened with a "-wal" file of another page size). The registry
only opens its own database, so this isn't reachable as deployed, but
it is the same upgrade the telemetry server and the root workspace
already made: rusqlite 0.41 resolves libsqlite3-sys 0.39.1, and
fallible_uint keeps the u64/usize conversions working as before (an
error past i64::MAX).

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

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

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

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

Part of #9370.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:33:22 +01:00
Horizon agentandClaude Opus 5.5 4be9343eca Pass trail corners by value to to_array
Vec3::to_array takes &self, so it can't be handed to array::map directly.

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

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

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

Fixes #13868

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-09 21:28:56 +02:00
Horizon agentandClaude Opus 5.5 03fd6705a1 Drop the unused shown binding in Seat::player_layers
worn_of decides what shows since the fx moved onto the hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
DUzzL 98ff540d65 Resolve a creative tab name given as a translation key 2026-11-09 20:27:25 +01:00
Horizon agent 115f5235b9 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:24:48 +01:00
voduffandClaude Opus 5.5 9d5f89a756 Scoreboard: /team and /scoreboard name holders and teams with vanilla's hover and insertion (#14279)
ScoreHolder.getFeedbackDisplayName replaces the display name's hover
with ShowText of the scoreboard name (an entity's UUID, a player's
name); /team and /scoreboard replies name holders with it. Horizon used
the plain display name. PlayerTeam's displayNameStyle also inserts the
team's name, which Horizon's formatted team name lacked.

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

Fixes #14279

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:28 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 f881b09c67 Merge remote-tracking branch 'origin/main' into prepared-data-stale-tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:23:27 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agent ec19b6320a Merge branch 'split/cosmetics-layers' into split/cosmetics-own 2026-11-09 20:20:01 +01:00
Horizon agent 16e29a2774 Merge branch 'split/cosmetics-draw' into split/cosmetics-layers 2026-11-09 20:20:00 +01:00
Horizon agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
DUzzL 624b34b3f9 Charge the host's armour for a dragon wing hit 2026-11-09 20:19:49 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
MSedgeMC 352a141050 port_map: the fish's despawn rule names its port
AbstractFish.removeWhenFarAway and Axolotl.removeWhenFarAway had no row, so
the ledger read the whole pair as unported; named_keeps_it carries it now.
2026-11-09 19:18:10 +00:00
MSedgeMC f6fbdbeebc Test the named fish that stays
Mob::despawn_check on a cod, a salmon and an axolotl 200 blocks from the
player: each with a custom name stays and each without one is removed, and a
zombie's name saves it nothing. Without the name half of removeWhenFarAway the
named cod is discarded like the unnamed one.
2026-11-09 19:18:10 +00:00
MSedgeMC f52894e0dc A named fish or axolotl never despawns
AbstractFish.removeWhenFarAway and Axolotl.removeWhenFarAway answer
`!fromBucket() && !hasCustomName()`, so a cod a player has named stays put
however far it swims, and one let out of a bucket is persistent before it.
Only the bucket half was ported: from_bucket is in
requires_custom_persistence, but check_despawn's removable flag knew nothing
of the name, so a name tag bought a fish nothing and it was discarded past the
128-block despawn distance like any other water mob.

named_keeps_it answers the name half for the fish and the axolotl, and
removable consults it beside the feline rule: a named cod, salmon, tropical
fish, pufferfish, tadpole or axolotl is never removed for distance, and no
other mob's name is persistence.
2026-11-09 19:18:10 +00:00
Horizon agentandClaude Opus 5.5 d016678e24 cosmetics test: the prop's root is the item's, whatever its name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:44 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
AkirabaneandClaude Sonnet 5.5 0fb13fa3c8 jmath: Math.log on Windows rounds like HotSpot's x86-64 stub, as sin and cos do
Windows' libm rounds log differently from Java's x86-64 intrinsic for about
0.1% of arguments (299 in 200,000 polar-method radii, #5064 point 1), and
Gaussian draws, density functions and a few block behaviours call it. The
platform's ln is replaced there by exact::log, in the module that already does
this for sin and cos: x split into a power of two and a mantissa in
[sqrt(1/2), sqrt(2)), ln m as 2 atanh((m - 1) / (m + 1)) in double-double with
a quick path checked against an error bound, and the full series with its
terms summed exactly where the bound leaves the rounding open.

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

Part of #5064.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:05:58 -03:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
DetimesandClaude Haiku 5.5 de6cc998df Native hooks: read the vehicle an entity rides and its passengers
Adds the read-only helpers native_vehicle_of(entity) and
native_passengers_of(entity) for server mods (vanilla Entity.getVehicle
and getPassengers, first = controlling passenger). Helper-only fragment
riding_getters.json; API number is assigned when the fragment is stamped.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 19:00:46 +00:00
DetimesandClaude Haiku 5.5 03b7d245b5 Native hooks: living_death_message (mob death message) (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:59:43 +00:00
Horizon agentandClaude Opus 5.5 4881a0862f Container screens spec: container_key gets the hovered slot, as the other inputs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:59:40 +01:00
voduffandClaude Opus 5.5 909d83c284 Commands: a mob's name carries its show_entity hover and UUID insertion (#14220)
Entity.getDisplayName adds a HoverEvent.ShowEntity (type, UUID, name)
and the UUID string as insertion to the team-formatted name. Horizon's
command replies named a mob with the team formatting only, and /summon
without NBT with a bare type name.

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

Fixes #14220

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:30 +00:00
Horizon agent 8df100e589 Merge the studio fixture fix into layers/studio-extras-gate 2026-11-09 19:57:58 +01:00
Horizon agent a416f1dfcd Merge the studio fixture fix into layers/replay-move 2026-11-09 19:57:55 +01:00
Horizon agentandClaude Opus 5.5 4c52129f7d Tests: capture fixture follows the Replay split
Replay playback moved out of Studio into game/replay.rs's Replay; the
pointer-capture fixture carries game.replay.driver and Game::studio_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:57:53 +01:00
DetimesandClaude Haiku 5.5 a6204f1f3a Native hooks: read-only entity lookup by UUID and entity data in any level (work in progress)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:57:08 +00:00
Horizon agentandClaude Opus 5.5 afa60454b2 Cosmetics: the own body and the inventory picture through PlayerDraw (spec #14091 step 2b-i)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:59 +01:00
Horizon agent 243d765452 Merge the studio fixture fix into layers/replay-state 2026-11-09 19:56:58 +01:00
Horizon agentandClaude Opus 5.5 ff52e244ae Tests: fixture Studio gets flying/replaying/active
fov.rs and the pointer-capture methods now ask self.studio.flying() and
replaying(); the fixtures mirror those three methods of game/studio.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:54 +01:00
Horizon agent bb712cca1a Merge the studio fixture fix into layers/studio-calls 2026-11-09 19:56:20 +01:00
Mik00000 c2dd2d935d perf(light): reuse scratch queues during incremental relight and preserve lazy section loading
- Reuse thread-local scratch queues (`RelightScratch`) across BFS light propagation passes, avoiding reallocation during incremental relighting.
- Pass borrowed slices `&[Pos]` to `relight_layer` to avoid redundant `Vec<Pos>` heap allocations.
- Cache consecutive vertical column lookups in `lt_sources` to avoid redundant map lookups.
- Preserve lazy loading of sections in `Region`, only loading and cloning sections that are actually read or written.
- Add `relight_single_block_bench` and update `relight_bench` to verify timings for both single-block and large batch edits.
2026-11-09 20:55:36 +02:00
Horizon agentandClaude Opus 5.5 1498fdecd3 Tests: fixtures read Studio's state from game.studio
test_compute_fov_hook and test_world_input_capture paste fov.rs and the
pointer-capture methods into a fixture Game; those now read
self.studio.{freecam,replay,rmb_down}, so the fixture carries a Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:14 +01:00
Horizon agentandClaude Opus 5.5 8cf1e6b4d6 Move a packet's entity removal out of remote.rs
remote.rs is over 3000 lines and may not grow (verify's "Big files don't
grow" failed): remove_entity, remove_entities and their test move to
game/remote_removal.rs, a child module of remote, unchanged.

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:54:33 +00:00
Horizon agentandClaude Opus 5.5 341a92902c Frog walk: keep FrogModel's 1.5 walk speed factor
FrogModel.setupAnim applies FROG_WALK with applyWalk(pos, speed, 1.5F, 2.5F)
on land (1.0F is the swim animation's).

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

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

Fixes #14261

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-09 18:49:41 +00:00
DetimesandClaude Haiku 5.5 9f04ad5318 Native hooks: read the dedicated server's online mode, port and proxy forwarding
Mods can read whether a dedicated server runs in online mode, the port it
listens on and whether it takes its players from a Velocity proxy
(forwarding.secret). The values come from the live network config and
listening socket, so they match what the server does. None off a dedicated
server and before it listens. The forwarding secret is never returned.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-11-09 18:47:11 +00:00
Horizon agentandClaude Opus 5.5 e859476293 test_world_input_capture: the move read is input_move now
tick_remote_level asks the input source (input_move) instead of pad_move, so
the order check (load completes, then the player moves) looks for that call
(verify run 42649031940).

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

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

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

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 21:42:56 +03:00
MSedgeMC 7a4aedf231 Merge the PR branch's test fix 2026-11-09 18:42:08 +00:00
MSedgeMC 8e45334760 test: give the piglin load test an entity to load
`mob_from_nbt` needs the entity's `id` and `Pos`; the fixture only had the
brain, so the test unwrapped `None`. Add both to the compound and to the
never-expiring variant.
2026-11-09 18:41:53 +00:00
Horizon agentandClaude Opus 5.5 89235891ce Container screens spec: the API as implemented (&mut reads, spread, Equipment, every menu's slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:38:19 +01:00
Horizon agentandClaude Opus 5.5 2081a0dd95 Merge main into #14127; be.json is main's translation minus the offline-name keys
Main retranslated 252 Belarusian launcher strings; this PR removes the 28
offline-name keys. be.json is main's file with those 28 keys taken out (7 of
them were among the retranslated ones); every lang file parses and none keeps
a removed key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:35:38 +01:00
Horizon agentandClaude Opus 5.5 cef35e64e7 horizon-hooks: std's Mutex by its path after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:32:42 +01:00
Horizon agent 3a9b05d21d Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
2026-11-09 19:31:04 +01:00
Horizon agentandClaude Opus 5.5 272fcfa34c mace.rs: pass the borrowed stack as it is (clippy needless_borrow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:27:53 +01:00
Horizon agentandClaude Opus 5.5 8534f12db0 Look spec: update, each crate its own fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:25:12 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agentandClaude Opus 5.5 7e95a1f57f stations tests: keep the registration-order doc on its own test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:37 +01:00
Horizon agentandClaude Opus 5.5 e0e4c5e9db docs/layers.md: spec of the look and the container screen API (layer plan M5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:22:25 +01:00
Horizon agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon agent da800dff55 Merge remote-tracking branch 'rv3n/14140' into rv3-fix-14140 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 997007191a Banner layers: leave out only the layer the codec rejects
TagValueInput.read returns the codec error's partial value, and DFU's list
codec keeps the valid elements as that partial (checked against
datafixerupper 10.0.21): vanilla keeps the other layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:03 +01:00
Horizon agentandClaude Opus 5.5 67f61f1c24 Test the piglin anger load on a whole saved piglin
The test loaded a compound with no id or Pos, so mob_from_nbt returned None
and it could not pass; it now saves the id, a position and the brain, and
checks a TTL past the counter's range too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:20:49 +01:00
Horizon-agent 723c4e7c2b Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 19:18:44 +01:00
Horizon-agent c32f43b4b0 Merge remote-tracking branch 'origin/main' into pr-13800 2026-11-09 19:18:37 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agent 0f0ca381d4 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 19:14:23 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Oloion e3d24ab1a5 mobs: fix frog walking keyframe animation in singleplayer
Remote entity client update now calls frog::walk_target for Species::Frog so limb_speed advances at the vanilla 25x distance rate instead of 4x. Also restore FrogAnimation.FROG_WALK speed factor to vanilla 1.0F in body_anim and guard keyframe looping against zero length.
2026-11-09 21:11:32 +03:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
MSedgeMC 385dc78afc Merge remote-tracking branch 'upstream/main' into fix/lectern-has-book-signal 2026-11-09 18:06:14 +00:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Horizon agentandClaude Opus 5.5 661a253ec4 Armor stand /damage: attribute and clear freeze in the existing branches
Main already attributes the hit branch and has entity_event_by. The
explosion, ignite and burn branches now take the cause in place, so the
separate refused function (which left them dead) is gone; causeDamage keeps
one name. The frost-walker test no longer shadows game() (it did not compile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:04:13 +01:00
Horizon agentandClaude Opus 5.5 ac0f7d6030 Studio's editor runs only with horizon-extras (layer plan M6)
studio::EDITOR = cfg!(feature = "horizon-extras"): without it the bars,
camera path, editor mouse and keys and the freecam/keyframe/path and
studio screen commands are off; recording and playback stay (platform).
The default build is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:04 +01:00
MSedgeMC 9790d9d81c Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 18:02:49 +00:00
MSedgeMC 2e9f807458 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 18:01:23 +00:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
MSedgeMC d56b769df5 Merge upstream/main 2026-11-09 18:00:08 +00:00
MSedgeMC ae0ab270ff Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:59:18 +00:00
Horizon agentandClaude Opus 5.5 42026ce09f Replay recording and playback move to game/replay.rs (layer plan M6)
Pure move: open/close/seek/advance, record start/stop/status, the world
snapshot and the record/replay words of /studio and horizon-ctl studio
leave game/studio.rs, which keeps the editor (free camera, path,
timeline, top bar, its own command words). Where playback touched the
editor it calls it: save_studio_camera_track, studio_replay_opened,
studio_replay_advanced, studio_replay_closing, studio_replay_closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:26 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
MSedgeMC 8d7ca30ea4 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:58:10 +00:00
Horizon agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01:00
Nikita Maximilian Grimm f25a5afc00 Merge remote-tracking branch 'origin/main' into record-start-copy 2026-11-09 18:53:46 +01:00
MSedgeMC e865160f46 Merge remote-tracking branch 'upstream/main' into fix/weapon-damage-component 2026-11-09 17:53:42 +00:00
Horizon agentandClaude Opus 5.5 6bb4c0eda9 Put a bounced projectile back through JoinList::put_back
creatures.rs is over 3000 lines and may not grow (verify's file-size check):
the join-order insertion moves to join_seq.rs, with a unit test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:40 +01:00
Nikita Maximilian Grimm cc03d693a2 Merge remote-tracking branch 'origin/main' into agent-e2e-windows 2026-11-09 18:53:36 +01:00
Horizon agentandClaude Opus 5.5 2f06f532e5 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:52 +01:00
Horizon agentandClaude Opus 5.5 ac0a2f1275 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:51 +01:00
MSedgeMC 092dee299b Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:52:13 +00:00
Horizon agentandClaude Opus 5.5 963782a17b Replay playback's state leaves Studio: game/replay.rs's Replay (layer plan M6)
Recording and playback are platform, Studio's editor is Horizon
(docs/layers.md, Replays): the driver, the camera type to restore,
standalone and the playback clock move to Replay; Studio keeps the free
camera, timeline and top bar. Callers ask self.replay.playing(); the own
player's tick fraction is Game::own_player_alpha(). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:07 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 d9ef984808 parity.py on Windows: record each harness's builds, and keep conflicting ones apart
The tracer read each command from the subprocess.Popen audit event, which on Windows carries the command line
Popen built, not the list: no harness's cargo build was recognised, so the probe prebuild built nothing and probes'
Rust sources never became dependencies. It now splits the line back (CommandLineToArgvW), and dep_info finds an
.exe's .d file.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:50:07 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agentandClaude Opus 5.5 23ca4a855a audio: silent short sounds stop again; drop the unused camera_epoch
- A channel of one frame or none was skipped before the mixing loop, so it
  never stopped and kept its channel; the inaudible fast path also computed
  (frames - 1) in usize, which underflows for an empty sound. Both now stop
  it, as the loop does; a step of 0 no longer stops a sound the loop keeps.
- Mixer::camera_epoch was written and never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:46:45 +01:00
MSedgeMC 16172da6b7 port_map: the chest and ender chest recheckOpen rows are ports
`Sim::tick_block`'s chest arm is `ChestBlock.tick`/`EnderChestBlock.tick`:
it recounts the openers through `recheck_container_openers`
(`ContainerOpenersCounter.recheckOpeners`), broadcasts the lid event and
re-schedules the five-tick tick while anyone has the container open. The
rows still said only the copper golems' openers were rechecked.
2026-11-09 17:46:19 +00:00
Horizon agentandClaude Opus 5.5 5720b15eff ui: the highlight flag and its test sit apart from #14094's
#14094 adds EditBox::centered beside text_color and a test at the end of
ui.rs's tests; this PR's field, initialiser and test touched the same lines.
Moved further down so both merge in either order. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:49 +01:00
Horizon agentandClaude Opus 5.5 2e754b1297 Polytone: the ticker timing test moves to its own PR; field() last
Keeps this PR under the project's 500-line limit (519 -> 468): the ignored
custom_ticker_cost timing test follows in a separate PR. Ctx's field() moves
to the end of its Host impl, so this merges cleanly beside #14089, which
changes the impl's own line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:18 +01:00
MSedgeMC 70c8d7089d port_map: the barrier pickup row's creative rule is checked
`Sim::pickup_block` returns false for a waterlogged barrier unless the
breaker is creative, which is `BarrierBlock.pickupBlock`'s gate; the row
said the rule was not there.
2026-11-09 17:45:14 +00:00
Horizon agentandClaude Opus 5.5 1ffa9ed25f Screenshots: a second F2 in the same second takes the next name
The PNG is now written on its own thread, so the first press's file may not
exist yet when a second press picks its name: both took the same path and
two writers raced on one file. screenshot_name also skips the paths of the
screenshots still being written (UiState::screenshot_messages).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:35 +01:00
Horizon agentandClaude Opus 5.5 4643ea9357 Iron golem: the defence goal sees the hating villager too
DefendVillageTargetGoal.canUse finds the villagers through attackTargeting
(forCombat().range(64)), which tests the golem's line of sight to each: a
villager behind a wall no longer sets the golem on the player. The PR body
named this change but the commit missed it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:12 +01:00
Horizon agentandClaude Opus 5.5 0022d3f42d Clamp a saved anger TTL past the counter's range instead of wrapping it
ExpirableValue's ttl is a long; narrowing it with `as i32` turned a TTL
above i32::MAX into a negative count, so the piglin loaded calm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:22 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Horizon agentandClaude Opus 5.5 38865e907e Studio: game.rs asks it through a few calls (layer plan M6)
game.rs's inline Studio blocks (bars, camera path, timeline drag, mouse
buttons, free-camera flight) and play_keys.rs's replay shortcuts move into
game/studio.rs methods; the engine reads Studio through replaying(),
flying() and active(). Pure move, no behaviour change.

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

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:36:52 +01:00
Nikita Maximilian Grimm b7615761ee Merge remote-tracking branch 'origin/main' into hdr-lighting-one-meaning
# Conflicts:
#	crates/horizon-client-render/src/gfx/output.rs
2026-11-09 18:34:44 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agentandClaude Opus 5.5 93e0536aca Studio: its state in one struct, game/studio.rs's Studio (layer plan M6)
Game's ten studio_* fields and last_replay_tick become one field, studio:
studio::Studio. Pure gather: every access is renamed, nothing else changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:54 +01:00
rdbtCVSandClaude Sonnet 5.5 c7a360b9bb Merge main into fix/h194-item-count-predicate-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:28:42 +00:00
Skr1zzzzandClaude Opus 5.5 396353c6b1 Client: one pass over the big entity lists per RemoveEntities packet
Part of #13007. Each removed id ran its own retain over the mobs, orbs, dropped
items, falling blocks and primed TNT, and a scan of the passenger map, so a
packet of n ids made n passes over lists that hold thousands of falling blocks
while sand drops. RemoveEntities now removes its ids together: the per-id side
effects keep their order (the dragon's nearest crystal still follows the
crystals one id at a time), and those five lists and the passenger map are
filtered once against the packet's ids.

8,000 falling blocks, packets of 256 ids (debug test build): 26.1 ms -> 1.3 ms
per packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:14 +03:00
Horizon agent 4c28a9b5b9 Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 18:26:09 +01:00
MSedgeMC ac1f560622 port_map: the piglin's guarded ANGRY_AT memory is read back
`tools/port_map/game2-004.tsv` recorded `Piglin.readAdditionalSaveData` as
reading the baby flag and inventory; it now also names the brain's
`minecraft:angry_at` UUID and TTL read by `mobs/piglin.rs::read_guarded_anger`.
2026-11-09 17:21:54 +00:00
MSedgeMC 6a4172369c test: a saved guarded anger loads its UUID and ticks
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A compound whose `Brain.memories."minecraft:angry_at"`
has the UUID `[90, 91, 92, 93]` and `ttl: 99L` loads a piglin with
`extra.anger == 99` and `guarded_angry_at == Some(Some([90, 91, 92, 93]))`.
A memory without a TTL (the codec's never-expires form) loads the longest
counter, `i32::MAX`.

Without the read both fields stay at their defaults: `left: 0`, `right: 99`
for the counter and `left: None`, `right: Some(Some([90, 91, 92, 93]))` for
the UUID.
2026-11-09 17:21:54 +00:00
MSedgeMC 06130ae40d fix: a piglin keeps the guarded anger its brain saved
`PiglinAi` sets `ANGRY_AT` with an expiry when a player breaks a guarded block
near a piglin, and `Brain.Packed` saves every memory with a codec under
`Brain.memories`: the value is the player's UUID and the TTL is the ticks left.
`LivingEntity.readAdditionalSaveData` reads that brain back.

Horizon wrote `minecraft:angry_at` in `mob_to_nbt` but `mob_from_nbt` never read
it: a piglin that a player had angered unloaded with its chunk and came back
with `extra.anger` 0 and no actor UUID, so it stopped attacking and the guarded
event was forgotten.

The load now reads the memory back: a four-int UUID into
`extra.piglin.guarded_angry_at` and the `ttl` into the existing `extra.anger`
counter, with no TTL read as the longest counter there is. The writer moves to
`mobs/piglin.rs` beside it, which also keeps `entity_nbt.rs` from growing.
2026-11-09 17:21:53 +00:00
MSedgeMC 9e68efc05b port_map: the destroy-progress stage and the daylight ticker rows are ports
`guest_dig_feedback` sends `(int)(progress * 10)`, the stage
`incrementDestroyProgress` computes, and `tick_daylight_detector` gates on
the dimension type's sky light, so the End is excluded like the Nether.
Both rows still described the old gaps.
2026-11-09 17:19:46 +00:00
Horizon agentandClaude Opus 5.5 8ac6a79035 rustfmt, Cargo.lock: horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:44 +01:00
Horizon agentandClaude Opus 5.5 153e0d20a4 horizon-discord: the hook's Client by its path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:32 +01:00
Horizon agentandClaude Opus 5.5 1e789800af launcher: remove offline names; play only as an account that owns the game
The project decided that Horizon plays only with a Microsoft account that
owns Minecraft: Java Edition. The launcher loses, in every build:

- the hidden Developer options section (seven clicks on the sidebar
  version) and Settings::dev_offline;
- offline names: adding, selecting and playing as one, the game's made-up
  name as a player, their skins and capes (skins.rs, skin_page.rs), the
  quick switch's and Settings > Account's offline entries, and the CLI's
  `account offline`, `account forget-offline` and `account skin`;
- the `--name` and HORIZON_OFFLINE_SKIN* it gave the game for them.

The game plays as the selected account (else the first), and
account::launch_for_game requires that account to own the game itself:
another account's ownership no longer lets it start under a name of its
own. With no network, its last answer still stands, as before.

Files from older launchers still load: accounts.json's `offline`,
`offline_selected` and `looks` and settings.json's `dev_offline` are
ignored on read and gone from the next save, which also deletes the old
names' skin and cape files.

The 28 strings only these used leave the 86 lang files and the
lang_review copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:05 +01:00
MSedgeMC 74e0245c47 port_map: EditBox.setInvertHighlightedTextColor 2026-11-09 17:17:21 +00:00
MSedgeMC e5eacec45c test: the anvil's selection keeps its colour
The box reports its own inversion off; a default box inverts; the selection
it would draw is the value.
2026-11-09 17:17:15 +00:00
Mik00000andClaude Opus 5.5 cf23e5001a perf(audio): optimize sound attenuation, spatial math, and batch channel updates
- Early-out for stationary listener in Audio::set_listener (bypasses mutex lock contention completely when camera does not move, ~5.25 ns vs ~36 ns)
- Eliminate redundant distance sqrt in stereo_gains: projections onto orthonormal basis preserve Euclidean distance, so reuse d.length()
- Replace asin in elevation with sqrt(1 - u^2) using identity cos(asin(u)) = sqrt(1 - u^2), avoiding inverse trig entirely
- Single-camera fast path in mix to avoid splitscreen camera search loop in normal single-player / multiplayer mode
- Distance squared early-out for linear attenuation (bypasses basis projections and stereo gains for inaudible sounds outside attenuation radius)
- Fast sample skipping for silent / zero-gain audio frames without decoding interpolation
- Batch channel parameter updates via Backend::update_channel, reducing 3 mutex lock acquisitions per ticking instance to 1
- Batch stopped query via Backend::filter_stopped, replacing N individual channel mutex locks per tick with a single query pass
- Avoid O(N*M) entity loop in Game::start_mob_sounds by collecting bee indices in initial filter pass

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:16:52 +02:00
MSedgeMC 0e9233b230 fix: the anvil's selected name keeps its colour
AnvilScreen sets setInvertHighlightedTextColor(false) on its rename box, so a
selection leaves the white text alone instead of painting it black on the
highlight. EditBox always inverted it.
2026-11-09 17:16:42 +00:00
Horizon agentandClaude Opus 5.5 01343707af horizon-discord: Discord Rich Presence leaves the engine for a Horizon crate
Layer plan M5: discord_presence.rs moves to the new Horizon crate
horizon-discord (file move), which the official executable registers on two
new platform hooks: Hooks::client_started (every game window, automated
runs included, never a dedicated server) and Hooks::activity (what the
player does, dispatched on change only, P1's). game/discord.rs becomes the
engine's game/activity.rs, which reports Activity (Hidden while the Discord
switch is off, until the switch moves to the settings store). The discord
Cargo feature goes: horizon-client's own executable has no presence.
discord_presence_e2e.py runs the official executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:42 +01:00
rdbtCVSandClaude Sonnet 5.5 b8ceaa3ed4 Merge main into fix/h211-shulker-drop-lock-2
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:09:21 +00:00
Horizon agentandClaude Opus 5.5 466201a864 Cargo.lock: horizon-official-servers after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agent ddb4f9df58 Merge branch 'split/official-servers-module' into split/official-servers-crate
# Conflicts:
#	Cargo.lock
#	docs/forking.md
2026-11-09 18:02:48 +01:00
itarqos5 0f23e5e538 Merge remote-tracking branch 'origin/main' into pr/particle-threads 2026-11-09 17:02:25 +00:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 84baabff3c Renderer: the HDR logo and the HUD kit's motion no longer read horizon-theme
horizon-client-render no longer depends on horizon-theme outside its tests
(layer plan M6):
- hud_kit::motion: a Tween takes a cubic-bezier curve; the solver lives
  here, tested against the design system's curves.
- The HDR calibration logo is the window icon given at startup.
- The font tests take Geist Mono as a dev-dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 6622e459b2 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 600a863354 Menu style: the settings pages and the tab strip take their colours from the layer above
menu_style (horizon-client-render): the colours of the settings pages
(graphics_style) and the HUD kit's tab strip, given once at startup;
vanilla's neutral greys when none is given. graphics_style's and tabs'
palette consts become functions reading it. horizon_look gives Horizon's
(layer plan M6; render -> theme goes in the next PR).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
rdbtCVSandClaude Sonnet 5.5 1b4f056692 Merge main into hdr-lighting-one-meaning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:44:13 +00:00
rdbtCVSandClaude Opus 5.5 15d2dd04c4 Screenshots: write F2's and the world icon's PNG off the render thread
The render thread waited for the GPU to finish the screenshot's frame
(poll wait_indefinitely), then converted the pixels and encoded the PNG
inline: at 4K or a Screenshot Quality above 1x, a second or more of frozen
game. F2's and the world icon's readbacks are now mapped asynchronously and
written on a thread of their own; the result reaches the chat a frame or a
few later. Agent screenshots and screenshot-sequence keep the synchronous
path, which hands them the result with the frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:50 +00:00
Vinícius MoreiraandClaude Opus 5.5 3b03b98836 Polytone: expression fields answered without their names
The fast path resolves the methods without arguments expressions read most (age, life,
custom, x, y, z, temperature, downfall) when the program compiles; a host may answer them by
that Field (Host::field) instead of comparing names, and one that doesn't, or a case it
doesn't cover, goes through the name as before, with the same value. The particles' host
answers a particle's own fields, the colormaps' block host its position and biome climate.
The equivalence test now also runs a host that answers fields.

Custom particle tick (custom_ticker_cost, release): 376 ns before, 344 ns now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 4e4767bbfe Polytone: a numeric fast path for expressions
A Polytone expression ran through the interpreter's Values and Results: about 263 ns for a
firefly's alpha that costs 7 ns written in Rust. An expression of one statement whose every
part is a number (constants, arithmetic, comparisons of numbers, math functions, ternaries,
and root.method(...) calls) now compiles a second time into a tree of f64s. It answers
exactly what the interpreter would: the result's kind (number, boolean, or a method's own
value at the top), method arguments as the interpreter passes them, Value::truthy for
methods in conditions; anything that could tell them apart (strings, == on a method's
value, locals, game functions) stays in the interpreter. A test runs a corpus of
expressions through both and compares every value and every host call.

Custom particle tick (custom_ticker_cost, release): 715 ns before, 376 ns now. A section's
colormap tints (colormap_tint_cost): 178 µs before, 135 µs now, same colours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 a2d34c7dbd Polytone: a timing test for custom particle tickers
custom_ticker_cost (ignored) ticks a firefly-like custom particle (five expressions with
sines and a named custom) and prints the time a tick takes, about 0.7 µs in release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
DUzzL e42acfc7ea Give the iron golem's poppy offer vanilla's range, sight and daylight gate, sight-test villagers in the defence goal, draw the repair pitch from the golem 2026-11-09 17:37:02 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
Horizon agent d82a6941d1 Merge remote-tracking branch 'origin/main' into pr-13800
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-09 17:19:40 +01:00
Horizon agentandClaude Opus 5.5 f7772b587f Natural spawning: the level keeps every player, the spawn state the anchoring ones
After main filtered fake players out of the spawn anchors, building the
level from that list would drop them from natural_level too; main builds
the level from every player and SpawnPlayer from the filtered list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:42 +01:00
Horizon-agent d1098c9a91 Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 17:17:21 +01:00
Horizon-agentandClaude Opus 5.5 d50d56d837 Natural spawning: shuffle every pass, no candidate cache keyed on chunk count
Util.shuffle in ServerChunkCache.tickChunks draws from the level's random
whether or not a category may spawn, so skipping it moved every later draw
of that random off vanilla's. The candidate cache keyed on player x/z and
the chunk count went stale when a chunk's ticket level or entity loading
changed under a player standing still (an AFK farm). Both go; the allocation
savings (streamed Counted, reused SpawnPlayer buffer, the single-player
collect) stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:21 +01:00
Horizon agentandClaude Opus 5.5 8e2780a8f0 Cargo.lock: horizon-official-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 1feb0c7b0b Keep every hopper's cooldown entry, as vanilla keeps tickedGameTime
With get_mut, an idle hopper never recorded the tick it ticked, so a
hopper that had already ticked this tick and then received an item from
another hopper took cooldown 8 instead of 7 (HopperBlockEntity.tryMoveInItem
skips one when its tickedGameTime >= the source's). Restore the per-tick
entry and the original prune; the other optimizations stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Tran Anh Van 96aa9791e6 Isolate ArmorStand.hurtServer changes to avoid 3-way merge overlap 2026-11-09 23:12:17 +07:00
Horizon agentandClaude Opus 5.5 8a5af429d7 Drop the offline capture reporting tools
They only re-project captures kept on the author's machine and start no
engine, so the test_*_parity.py name would register a harness CI cannot
run. The Rust fix and its unit and guest-death tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:50 +01:00
Horizon agentandClaude Opus 5.5 2cd4ec9888 horizon-official-servers: the official server leaves the engine for a Horizon crate
Layer plan M5: official_servers.rs moves (pure file move plus its hook) to
the new Horizon crate horizon-official-servers, which the official
executable registers through the platform's hooks: Hooks::started now gets
the game folder (Started::game_dir), and horizon-platform re-exports
servers.dat as horizon_platform::servers. Its addresses come from
horizon-addresses: the withdrawn partner's joins the list file, so its
allowed entry in tools/addresses.json goes. horizon-client's own executable
(plain builds, harnesses) no longer touches servers.dat at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:24 +01:00
Horizon agentandClaude Opus 5.5 45a2f32631 particles.rs doesn't grow: the threaded tick moves to particles/simple.rs
tick_simple, Simple, sprite_from_age_in, tick_all, finish_simple and the thread
settings move unchanged into a child module; particles.rs keeps the calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:04:48 +01:00
MSedgeMC 02a8a61de4 Weapon wear comes from a stack's weapon component
ItemStack.postHurtEnemy uses Weapon.itemDamagePerAttack from the stack's
component. Horizon read the item prototype's weapon damage, so a patched
weapon wore 0 or the prototype's amount instead of its own.

Test: a diamond sword with item_damage_per_attack 3 wears 3 per hit.
2026-11-09 16:00:01 +00:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
MSedgeMC f0c1ec9d84 A stonecutter result keeps its components
StonecuttingRecipe.assemble creates the result from ItemStackTemplate, so a
result's components (a data pack's custom name, damage, ...) belong on the
output. Horizon built the result from its id and count only.

Test: a stonecutting recipe whose stone result names it "Cut Stone" yields a
named stone.
2026-11-09 15:59:33 +00:00
Tran Anh Van c66936f747 Sync sculk entity_event_by and entity_killed_event_by with upstream main 2026-11-09 22:54:38 +07:00
Horizon agentandClaude Opus 5.5 755b7800c1 Input source: what gameplay tells the pad goes through engine methods too
input_keyboard_used, input_made_pointer_event, input_player_replaced,
input_takes_stack_drop and input_hud_begin replace gameplay's direct writes to
the pad's state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:51:53 +01:00
Horizon-agentandClaude Opus 5.5 1d52dd41ba Give the HAS_BOOK lectern test its own doc and a positive control
The new test was inserted between the getRedstoneSignal doc comment and the
test it documents. It now also checks that a lectern whose state has
has_book=true reads the book signal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:21 +00:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
MrCloudy2andClaude Opus 5.5 30149bfb41 Harness: an arrow bouncing off a wind charge keeps its place in the tick order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:47:59 +01:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
Ludovic MarieandClaude Opus 5.5 0031c67d04 rustfmt explosion.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:14 +01:00
MrCloudy2andClaude Opus 5.5 2726614191 A projectile put back after its hit keeps its place in the tick order
An arrow that bounces off an entity (deflect REVERSE) never leaves the level
in vanilla, so it keeps its place in EntityTickList. Horizon's projectile
pass drops it and finish_arrow_hit adds it again with add_projectile, which
appended it behind every projectile already waiting: those then ticked
before it. add_projectile now puts a projectile that has already joined back
at its join position; a new one still goes last.

Found with the Orbital Skybender: the arrow that reads each digit missed
every other wind charge, so only half the digits were read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:23 +01:00
martipopsandClaude Opus 5.5 fb23266b6e Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 d7d1e72afc Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
The radial menu's overlay_holds_view gate moves with debug_crosshair_drawn into
game/hud_render.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 2a5ec67594 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:17 -05:00
MSedgeMC 6a68afa6cc A banner with an invalid layer loads no patterns
BannerPatternLayers.CODEC reads its layer list all or nothing: one layer the
codec rejects leaves the empty layer list. Horizon skipped only the bad layer,
so a banner kept the layers before it.
2026-11-09 15:32:06 +00:00
Horizon agent 3c5c7d6077 Merge remote-tracking branch 'origin/main' into fix-11484
# Conflicts:
#	crates/horizon-client/src/game/nether_portal.rs
2026-11-09 16:31:19 +01:00
Horizon agent ef85f6dd7b Merge main into threaded particle ticks
# Conflicts:
#	crates/horizon-client-render/src/particles.rs
2026-11-09 16:24:19 +01:00
Ludovic MarieandClaude Opus 5.5 5152376c4b input_feedback: commands already have a feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:14 +01:00
Horizon agent d93a9a4be4 Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:21:50 +01:00
Horizon agentandClaude Opus 5.5 2b6aed3dc7 Leave the other harnesses' work folders as on main
A run- folder per invocation under .local/<harness>/ is never removed by runner-gc (it only
looks one level down), and these four harnesses aren't this fix's. Main's harness fixes own them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:48 +01:00
Horizon agentandClaude Opus 5.5 8f331a10f5 Input source: gameplay asks controllers through engine methods (layer plan M6)
Gameplay reads the analog move vector, held mappings and the view turn, and
reports what the player may feel (Feedback), through input_move, input_holds,
input_look and feedback in play_input.rs. Only these four bodies name the pad
modules, which answer today; Feedback is an engine type the pad maps to Rumble.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:35 +01:00
Horizon agent 6db95ffc1e Merge remote-tracking branch 'origin/main' into fix-11536 2026-11-09 16:14:15 +01:00
MSedgeMC 97ab5f100a port_map: tick_item_activation is in game/totem.rs
The row's note still said game.rs after the totem split. The function is in
game/totem.rs and both tick paths call it.
2026-11-09 14:35:28 +00:00
Riccardo GuerrieroandClaude Opus 5.5 a3c2a271ef Move the breed goal tests out of goals.rs
goals.rs is over the 3000-line limit and may not grow: the two Breed
goal test modules move to their own files, and the range check uses
Mob::distance_sqr, so goals.rs ends shorter than on main.

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

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 14:37:39 +01:00
Horizon agentandClaude Opus 5.5 cef8682971 Drop the item count fixture nothing runs
Its name matches no harness pattern; test_command_exec_parity.py --only
"item count predicate" already compares the group with vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:44:27 +01:00
Horizon agentandClaude Opus 5.5 67d694dd9c shulker_lock_drop runs by name only
It needs --work-dir and --reference-dir, which an automatic parity run
doesn't pass, so without the named_only entry it fails at startup on
every pull request that selects it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:43:10 +01:00
Horizon agentandClaude Opus 5.5 7b25b8ed80 Sculk reload particle: interpolate in doubles as vanilla does
VibrationSystem.Ticker.tryReloadVibrationParticle computes alpha as a double.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:35:18 +01:00
Florian Ernst c2e4f28c3f Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:25:01 +01:00
Florian Ernst 8e26753cda Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:59 +01:00
Florian Ernst 8fe0b417be Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:57 +01:00
Horizon agentandClaude Opus 5.5 95a3721df1 perf(villagers): list a column's POI sections under one lookup
A villager's POI search (AcquirePoi, occupied POIs, homes) visited each
section of 9x9 columns through PoiSections::get: a mutex lock, a hash
lookup and an Arc clone per section. PoiSections now keys its lists by
column, and PoiSections::column takes the lock and finds the column once,
handing back only the sections that hold POIs, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:04:32 +01:00
voidsip edfe18495e Keep reputation damage and visibility helpers in focused modules 2026-11-09 05:35:03 -06:00
voidsip 8ede2ff197 Keep potion sound regression in its own module 2026-11-09 05:22:24 -06:00
voidsip f1696089ef Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11329-villager-reputation-events
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-09 05:20:13 -06:00
voidsip a4925a7954 Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/11336-potion-contents 2026-11-09 05:19:15 -06:00
voidsip 085b4109e3 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11336-potion-contents 2026-11-09 05:19:05 -06:00
voidsip 4a6942b8e9 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/11329-villager-reputation-events 2026-11-09 05:18:51 -06:00
RINNECODERandClaude Opus 5.5 13a8b0c3fe OptiFine CEM: a pre-1.21.5 pack's mob texture paints its old models and the climate variants
A pack made before 1.21.5 ships entity/pig/pig.png, cow.png,
red_mooshroom.png, brown_mooshroom.png and entity/chicken.png in the old
64x32 layout, and its .jem files measure their UVs on them. 26.3 draws
the renamed pig_temperate (64x64) through those UVs: misplaced,
see-through panels. Cold pigs, climate cows and mooshrooms kept
vanilla's textures.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:55:41 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 b2ba0cb395 HDR settings: HDR Lighting greyed under a shader pack, one switch for it
HDR Lighting is one thing: all of Horizon's own lift above paper white, light
sources, the sun and the lit surfaces. HORIZON_HDR_LIGHTING=0 switched off only
the surfaces' part, a second switch with another scope; it is gone, and the
setting does the job. docs/graphics.md now describes the setting.

Under a shaders/ pack, and under a 0.2 pack on the float frame, Horizon lifts
nothing, so the row did nothing there. It is now greyed and says "The shader
pack lights the world itself." Under a 0.1 pack on the 8- or 16-bit frame the
last pass still lifts the light sources and the sun, so the row stays live
there, as it does for a pack that is refused, missing or whose mod is off. The
page reads this from the renderer's output, which now carries whether the
running pack lights the world. HDR's own reasons still come first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:21:31 +01:00
Mik00000 72f748a785 perf(machines): optimize block entity ticking and hopper transfers 2026-11-09 12:11:26 +02:00
Florian ErnstandClaude Opus 5.5 6ddde6fcde Keep game.rs from growing: declare the allay test module in allays.rs
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:53:49 +01:00
Florian ErnstandClaude Opus 5.5 1efd1c20c9 Keep game.rs from growing: Pending::record_from reads name and lock
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. Breaking a block entity and dropping its resources both
read the custom name and lock from the world, so a helper does it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:52:54 +01:00
Florian ErnstandClaude Opus 5.5 d8c00ad64e Keep commands.rs from growing: count matcher and tests in their own files
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. The count bounds matcher moves to commands/item_count.rs
and the ignored test module to game/item_count_predicate_tests.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:52:21 +01:00
TheMrZZandCodex 9ad798b74b Preserve personal spawnpoint pitch for dedicated guests
Part of #13625. Keep owner physical pitch open and document recovered offline arrival evidence.

Co-Authored-By: Codex <[email protected]>
2026-11-09 10:33:51 +01:00
MSedgeMC 4a296fffb2 A lectern without its has_book state feeds no comparator
LecternBlock.getAnalogOutputSignal returns 0 unless the block state has
HAS_BOOK, before it asks the block entity for its page signal. Horizon used
the block entity's book alone, so a lectern whose state says it has no book
still fed a comparator.
2026-11-09 09:04:21 +00:00
Florian ErnstandClaude Opus 5.5 770f30306a An allay dances to the jukebox it heard first
Squashed and rebased onto main from #12466.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
Florian ErnstandClaude Opus 5.5 e2f472508d Preserve external locks on destroyed shulker boxes
Squashed and rebased onto main from #11627.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
Florian ErnstandClaude Opus 5.5 38060a67ba Match item count equality against the actual stack
Squashed and rebased onto main from #11658.

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

pauses_game now asks the dialog, then the book, then the screen, the way each
of them is its own screen over the last.
2026-11-09 08:04:10 +00:00
martipopsandClaude Opus 5.5 f3e4420699 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Main's renderer output for the graphics screen and HDR calibration moves with
shell_mut and shell_events into options_ui/shell_pages.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:03:26 -05:00
martipopsandClaude Opus 5.5 db3f211cfa Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:40 -05:00
martipopsandClaude Opus 5.5 dc2aaacea3 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:39 -05:00
Vinícius MoreiraandClaude Opus 5.5 41fc640974 A player's break of an armor stand rolls its loot table
ArmorStand.hurtServer breaks a stand through brokenByPlayer on a second
player hit within five ticks, and at once for an always_kills_armor_stands
type: the stand item (with the stand's name) pops at its block, then
brokenByAnything plays the sound, rolls the loot table and pops the
equipment. Horizon's punch dropped the item and equipment without the
loot table, and /damage refused every can_break / always_kills type.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:35:28 -03:00
AppaandCodex 300d65c317 Preserve piston restitution outside the unoccupied mob slice
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 23:13:12 -08:00
AppaandCodex 18c8cc5eb9 Cover partial piston landings and the mob bounce sync marker
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:58:07 -08:00
AppaandCodex f9baaaed27 Limit the ground-state change to vertical NoAI pushes
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:24:42 -08:00
fc4a51753d Feeding a horse or camel makes a vibration and a Silent one eats quietly
`AbstractHorse.handleEating` and `Camel.handleEating` play the eating sound
only when the mount is not Silent, at its feet, with a pitch from the mount's
own random, and then post `GameEvent.EAT` from the mount. A growing baby's
HAPPY_VILLAGER position draws three doubles from that random first (the
server's `Level.addParticle` shows nothing; the client draws its own).
Horizon played the sound for Silent mounts too, at the body's centre with
the level's random, and posted no game event, so sculk sensors and wardens
never heard a mount eat.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
AppaandCodex 72352e0006 Keep the piston mob fix on the authoritative path
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:18:07 -08:00
AppaandCodex d4da822ac2 Keep piston bounce fixture gamerules unchanged
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:06:44 -08:00
AppaandCodex 26f8689238 Use mob restitution for piston pushes onto slime
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:01:33 -08:00
martipopsandClaude Opus 5.5 9a43f998c1 Docs (UI kit): the shell's lists, steps and opened focus
Screen::choose, Hosted::clicks_step, Shell::focus_soon and
changed_elsewhere, the binding row's chosen text, Capture::Pad on any
shell screen, and where a shell screen is hosted (options_ui/shell_pages.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:08 -05:00
martipopsandClaude Opus 5.5 2cdde29154 Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:38 -05:00
martipopsandClaude Opus 5.5 7816e1adbb Docs (UI kit): overlays that hold the view, through overlay_holds_view
What vanilla draws in the middle of the view (the crosshairs, the chat's
lines, the toasts, the hands) gives way to an overlay through one query,
so the next overlay adds its condition there rather than to each piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 d94ceb2eb9 Merge controller/dial-2-model into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 4843a2b975 Docs (UI kit): who asks pad_capture, and how a new overlay joins it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:42:32 -05:00
martipopsandClaude Opus 5.5 0d7c032320 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:47 -05:00
martipopsandClaude Opus 5.5 932fea900e Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Main moved hand_geometry and first_person_arm to game/frame_geometry.rs:
the hands still come from hands_shown there, so an overlay that holds
the view hides them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:42 -05:00
martipopsandClaude Opus 5.5 a53de4943f Merge upstream/main (a758c3eb20) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:11 -05:00
martipopsandClaude Opus 5.5 2aee551905 Options: the shell pages' glue in options_ui/shell_pages.rs
Which shell each page opens, what a shell's outputs do, and the radial
menu's name entry and rows move out of options_ui.rs as they are, so the
file doesn't grow. pad_clicks_step now also sets Shaders' shell back,
which main put on the shell since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
martipopsandClaude Opus 5.5 599ef2acd3 Settings shell: its tests in settings_shell/tests.rs
A pure move: the tests module leaves settings_shell.rs as it is, so the
file is under 3000 lines (docs/layers.md#file-size) and the radial
editor's additions to the shell don't grow a file over the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
voidsip 42b04e0238 Update reputation damage and guest fixtures for main 2026-11-08 20:23:13 -06:00
martipopsandClaude Opus 5.5 9969321db4 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Main put Shaders on the settings shell and made Screen::undo return
outputs: the radial screens' sheets use Sheet::new and their undo returns
none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:22:10 -05:00
martipopsandClaude Opus 5.5 acfa08d9e7 Controllers (radial): one overlay_holds_view query, so game.rs and remote.rs don't grow
game/hud_room.rs answers whether an overlay holds the view (the radial
menu, once it shows), and the 2D and F3 crosshairs, the chat, the toasts
and the hands ask it. The four toast draws go into draw_every_toast and
the hands into hands_shown there; draw_pad_over_hud draws the menu and
then the pad's cursor. The respawn no longer closes the menu itself: the
new player's first position does, through the one pad_player_replaced
call in remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 91d32f40bc Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 1691d8d65b Controllers (radial): the radial menu's file settings in gamepad/radial_file.rs
RadialMode, RadialStick, the opener's thumb rule, each stick's session
opener, can_bind, set_radial_stick and their tests move out of
gamepad.rs as they are, so gamepad.rs stays under 3000 lines. The paths
under horizon_client_input::gamepad stay the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 408b3dc47d Controllers: pad_capture is a module of game/pad, so game.rs doesn't grow
game/pad_capture.rs moves to game/pad/capture.rs as it is, its items
visible to the gameplay module as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 d55b4b0a78 Merge controller/dial-1-kit (upstream/main) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:07:52 -05:00
voidsip b37b703f1c Run portal cooldown proof in a scratch game directory 2026-11-08 19:19:22 -06:00
voidsip 94cad99398 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11336-potion-contents 2026-11-08 18:41:12 -06:00
martipopsandClaude Opus 5.5 1a1e4c6f46 Controllers (radial): edit the menu's pages on the settings shell
Controller Settings > Bindings > Radial Menu... opens the pages on
main's settings shell (radial_editor.rs): a sheet per page with its
eight slots by direction, then Rename Page, New Page and Delete Page,
then Reset All Pages and the opener's row.

- A press on a slot opens the list of every action and Empty for it,
  which names the slot, marks what it holds and opens on it; Right and
  Left still step it (`Screen::choose`, and a controller's Left and
  Right count as steps, `pad_clicks_step`). Back from the list, the
  focus is on the slot again (`Shell::focus_soon`).
- Rename opens a small page with a name field, Done and Cancel.
- Delete and Reset act at once; Undo puts the pages and the opener back
  as the screen found them. At most 6 pages, at least one.
- The opener's row is a `Value::Binding` on `Capture::Pad`: it waits in
  place for a button, refuses the ones under the selecting thumb and B,
  and its strip says when no button opens the menu or another action
  shares it.
- Controller Settings' own Undo leaves the pages to this screen.

e2e: radial_editor, and Settings Pages reaches the new page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 7008bc32ba Controllers (radial): e2e for the radial menu in play
tools/pad_menu_e2e.py gains 28 radial_* cases, each a real offscreen game
driven through horizon-ctl's virtual pad: commits, retention, pages,
cancels (B, Start, a disconnect, focus loss, death with immediate
respawn), the camera and the walking stick, menus and the on-screen
keyboard keeping the opener, held sneak and drop, D-pad rolls after a
commit or a cancel, RT/keys/clicks fighting back, Toggle mode, the left
stick and Either, a bumper opener, haptics, narration, unavailable and
missing actions, a file from before the menu, Nintendo glyphs and the
status rows. A case can seed the controller file and pass extra launch
arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 2ccc6f7ffe Controllers (radial): Hold or Toggle, and the stick that selects
Controller Settings > General gains a Radial Menu group on main's
settings shell: Radial Menu (Hold, the default, or Toggle) and Radial
Menu Selects With (Right Stick, Left Stick or Either), each describing
its value in the strip, and stepping with Left and Right as every shell
choice does. Stepping the stick goes through `set_radial_stick`, so an
opener under the thumb that now selects is let go, and stepping back
gives it again. e2e: radial_stick_back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 a14242eb15 Controllers (radial): the menu's settings in the controller file
PadSettings gains the radial pages, `radialMode` and `radialStick`, and
the opener's binding, `pad.radial` on D-pad Right; nothing in play reads
the opener yet (it routes as a held button until the menu does).

- `bindLayout:2`: once, for a file from before the opener, a button the
  player bound by hand keeps it, and the opener goes unbound rather than
  sharing it. A file from before the binds list's defaults binds none.
- An opener under the thumb that selects, or on B, can't be bound
  (`can_bind`), and a file with one reads as never saved.
- `set_radial_stick`: a stick that takes the opener's thumb unbinds it;
  stepping back gives each stick the opener it had this session, else
  D-pad Right where it's free.
- Lines this game doesn't read (a newer Horizon's settings, an action it
  doesn't have, a button it doesn't know) are kept as they were, and a
  newer `bindLayout` is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d747ae6c26 Controllers (radial): hold D-pad Right for a radial menu of actions
The radial menu in play (game/pad/radial_play.rs): hold the opener and
the selecting stick points at one of eight slots; letting go runs the
action once, through the same path as its button. Bumpers turn the
pages. `pad.radial` now routes to it, and the dial draws over play with
the game's icon painter and the frame's motion clock.

- Hold or Toggle, and the right, left or either stick, from the file.
- B, Start, a disconnect, focus loss, a new player (death with
  immediate respawn, the End's exit) or a screen cancels; RT/LT, any key
  or mouse click cancels and goes on. A flick between polls counts (the
  pads' peaks, splitscreen's router included). Holds from before the
  open keep working; a held drop restarts its wait.
- A D-pad roll off the opener reaches nothing for 100 ms after the later
  of the close and the opener's release.
- What can't run is dimmed: Emote without cosmetics, Quick Actions
  without a dialog; Social in single player shows vanilla's "not
  available" message, as its key now does.
- While it shows: the chat lines, toasts, crosshair (F3's too), guide
  and first-person hand give way; the status rows are kept clear.
- Haptics on reaching a runnable slot, a page turn and a release that
  ran nothing; narration of the page and a settled slot.
- horizon-ctl: `pad focus true|false` sends a real focus event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 a9e60a6782 Controllers (radial): pages of eight slots, and their text
radial.rs gains what the menu's pages hold: `RadialAction` (actions that
do everything on their press; Horizon's own namespaced, vanilla's by
their key mapping, old ids read through an alias table), `Slot` (an
action, Empty, or a Missing id kept as it was, which never runs), named
`Page`s of a kind (`Fixed` slots, or `Hotbar`, read and kept), and the
default General and Camera presets, which hold only actions with no
button of their own.

`Pages::parse` and `serialize`: the `radial_<n>_*` lines of
horizon-controller.txt. A page the player hasn't changed is saved as its
preset, so it follows the defaults; `radialLayout` is read and a newer
one kept; unknown fields, kinds and slot ids past the eighth survive a
save; up to 16 pages are read and the menu turns through 6. Edits:
set_slot, rename, add, remove.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 7a92b5064c Controllers (radial): the dial's hub, page tabs and Cancel hint
The hub names the selection on a dark disc: the action, and for
Perspective, Hide HUD and Cinematic Camera its state and what a press
turns it to; for a slot that can't run, why; for a missing action, its
id; while a stick must be let go first, "Let go of the stick". The large
icon goes above the name when the hub has room, and its text stays off
every tile (`fit::fit_in_circle`). Toggle tiles get state chips where
there's room for them.

Along the top, the pages are a `tabs::tab_strip` between the bumpers'
glyphs; collapsed, the bumpers name the pages they turn to. With a
bumper for the opener only the other shows. B's Cancel sits on a plate
at the lower right when the view is roomy, in the player's glyphs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 87346bb92f Controllers (radial): the menu's model, held open by a button
horizon-client-input's radial.rs: `RadialMenu`, what the opener, the
selecting stick and the bumpers do while the menu is open, knowing pages
only by their count and slots by their index. Eight slots clockwise from
the top; a release commits once; 250 ms of neutral retention; a stick
leant since before the press waits to be let go (`StickHistory`), while a
flick that starts with the press selects; tilts that follow the
deadzone (`Tilt`); toggle mode's latch, with A running the slot settled
on (`A_SETTLE`); `DpadSettle` for a thumb rolling off the opener; and
`Settle` for narration. Every tuning value is at the top of the file.

gamepad.rs: `RadialMode` and `RadialStick`, `opener_refused` (the opener
is never under a thumb that selects, nor on B) and the radial haptics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d16c9137f9 Controllers (radial): the dial's ring and tiles
radial_overlay.rs draws the radial menu over play, in the look the
mockups settled on (`docs/ui-kit.md`'s pieces): a dark vignette, the
ring's track with the selected 45° sector lit, and eight tiles on true
45° rays, each vanilla's game mode switcher slot with its action's icon
from the player's own resources. An empty slot is a pip; one that can't
run is dimmed and slashed; a missing action shows its mark. The sizes
follow the view's height (`Layout`, the scaling rule in its docs), and
the ring keeps below the boss bars and above the status rows over the
hotbar (`Keep::StatusRows`, which the HUD's room gains). Its motion
(`Look`) is quiet and never selects: a fade and scale in, the sector
easing to a new slot, the tiles sliding in on a page turn, a commit's
flash and a fade out.

Nothing draws it yet. The hub, the page tabs and the Cancel hint come
next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 0e4163f67f Controllers: one routing point for what owns the pad
game/pad_capture.rs: `Game::pad_capture` says which overlay over play
owns the controller now, in one priority order (the on-screen keyboard,
then a prompt over the world). The keyboard checks in pad.rs,
pad_actions.rs, pad_menu.rs and the gameplay guide ask
`pad_keyboard_captures()`. Behaviour is unchanged; the radial menu adds
its place in the order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
voidsip d7dc5628e2 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11329-villager-reputation-events 2026-11-08 18:38:26 -06:00
voidsip 8b45d15486 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/11318-portal-cooldowns 2026-11-08 18:38:26 -06:00
voidsip e8f93ffc67 Merge main into villager reputation fix 2026-11-08 18:38:25 -06:00
voidsip 2e0f86f312 Merge main and preserve entity portal cooldown phases 2026-11-08 18:38:25 -06:00
voidsip 167caf28d0 Merge commit '976346aff5bfa09c6d8e364c347bdf1791618a98' into fix/11336-potion-contents 2026-11-08 18:13:04 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 d0f851fca9 agent_control_e2e passes again: the screen commands wait for the first frame
- A game answers its control socket before it has drawn a frame: `ui`, `click`, `set`,
  `screen` and `click-button`, which read the last drawn frame, now wait for the first one
  (UiWait), as `ui`, `click` and `set` already wait for a frame after a click.
- The ui scenario expects Create New World's Game tab, the manage scenario a widget the
  Horizon options page still has (ai-agent), and the options scenario takes GUI scale 2 as
  half the GUI of scale 1, so display scaling other than 100 % passes.
- The no-ai check honours HORIZON_E2E_HEADLESS like the other scenarios.

Fixes #12972

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

Fixes #12977

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
voidsip 36bad2c06e Give parity harnesses fresh default capture directories 2026-11-07 20:35:20 -06:00
voidsip 7c7bca2e7b Update potion fixture for silent impact argument 2026-11-07 20:35:19 -06:00
voidsip 58552b8cb9 Merge commit 'bc6016d19d4dd6f11f6b954fc3b121546f069ffb' into fix/11336-potion-contents 2026-11-07 20:25:35 -06:00
Evrim Cicek 3cd63747b9 Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 02:37:22 +03:00
b064c12aca Let sculk hear item frame changes
Post BLOCK_CHANGE at the frame from ItemFrame.interact, hurtServer and
dropItem, after the changes and drops vanilla performs first. Preserve
BlockAttachedEntity.kill ordering and discard unsupported frames without
a spurious ENTITY_DIE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:13 +01:00
Horizon agentandClaude Opus 5.5 959a89ef67 Merge origin/main into #11559
/damage: keeps this branch's villager reputation flush after a hurt and
main's enderman teleport settle (they act on different mobs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:51:59 +01:00
Evrim Cicek 99f76baf29 Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 16:53:40 +03:00
Horizon agentandClaude Opus 5.5 1cf0e13b61 Merge origin/main into fix/11329-villager-reputation-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:05:20 +01:00
Horizon agentandClaude Opus 5.5 37541674a9 Merge origin/main into fix/11336-potion-contents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:04:25 +01:00
Horizon agentandClaude Opus 5.5 18e0854d00 Merge main into fix/11329-villager-reputation-events
Main split projectile_hit into a wrapper and projectile_hit_inner: the
reputation flush now runs in the wrapper after the inner hit. Trident and
wind charge hits keep both the reputation flush and main's absorbed-damage
and post-attack calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:09:28 +01:00
Horizon agentandClaude Opus 5.5 04914f8c33 Merge main into fix/11336-potion-contents
Keep both sides of the potions.rs test modules; the contents fixture passes
the impact motion that main's splash now takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:07:05 +01:00
Horizon agentandClaude Opus 5.5 c2a2fe370f Skip entities outside a gateway before the per-id lookups
entity_portal_delay walks every entity list; it ran for every item, mob,
boat, cart and pearl in the End each tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:48 +01:00
voidsip 65d35f3261 Initialize configuration history for reputation test guests 2026-11-06 22:02:27 -06:00
voidsip b4dea2a473 Merge commit '77936bf4bee3ec7d641f54b97a3da09f9b72e213' into fix/11329-villager-reputation-events 2026-11-06 21:52:56 -06:00
voidsip bfa20f0413 Initialize upstream native command fields in reputation fixture 2026-11-06 21:31:46 -06:00
voidsip 05c7788a8f Merge commit '41944ed91474c6af28572d2e2273b6b2bd0bcf1e' into fix/11329-villager-reputation-events 2026-11-06 21:22:00 -06:00
voidsip 35ab895667 Validate villager reputation with isolated game and vanilla fixtures 2026-11-06 19:47:29 -06:00
voidsip 178a2bf8b4 Correct isolated potion fixture admission and default invocation 2026-11-06 18:36:13 -06:00
voidsip 673a2515e8 Add stock-compatible accepted-hurt regression fixture 2026-11-06 17:55:03 -06:00
voidsip 93d57cf58c Validate potion components with isolated vanilla methods 2026-11-06 17:52:39 -06:00
Evrim Cicek 52939c99ab Merge remote-tracking branch 'upstream/main' into fix/vehicle-item-stack-config
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-07 02:30:24 +03:00
voidsip 4f935ae226 Use the standard prepared resource path in the focused harness 2026-11-06 17:28:28 -06:00
voidsip 8eba763b33 Keep legacy cooldown fixture type explicit 2026-11-06 17:14:40 -06:00
voidsip 85029c85e5 Verify portal counters without starting a server 2026-11-06 17:14:13 -06:00
voidsip e08699893a Merge current main into villager reputation fix 2026-11-06 16:15:57 -06:00
voidsip b714d73e46 Deliver villager reputation from accepted damage sources 2026-11-06 16:13:29 -06:00
voidsip 72cd6e42ee Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11336-potion-contents 2026-11-06 16:13:16 -06:00
voidsip 9baf83e129 Fix thrown potion stack components and arrow expiration 2026-11-06 16:13:02 -06:00
voidsip 2d04344265 Merge commit '9d11d0c710705ee0901c2186d78641048a6660b0' into fix/11318-portal-cooldowns 2026-11-06 16:12:27 -06:00
voidsip 226e365523 Use entity-specific portal cooldowns and preserve live counters 2026-11-06 16:12:12 -06:00
voidsip bcc1264dff Share identity-aware merchant price refresh helpers 2026-11-06 15:33:49 -06:00
Evrim CicekandClaude Opus 5.5 8590b6e650 A vehicle from an item also takes its custom_data, and a boat keeps its placing yaw
Review: Entity.applyImplicitComponent applies two components, the custom
name and minecraft:custom_data (saved as `data`), and BoatItem.use /
BoatDispenseItemBehavior turn the boat to the player's or dispenser's yaw
after the stack's config, so entity_data's Rotation does not keep it.
Also moves place_minecart's doc line back onto it.

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

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

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

Fixes #11195

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

No files matched your search

-1
View File
@@ -13,4 +13,3 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
+34
View File
@@ -0,0 +1,34 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
+7
View File
@@ -20,6 +20,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
blocks, `--request-changes` when something does.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
+2
View File
@@ -198,3 +198,5 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
Generated
+67
View File
@@ -706,6 +706,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -926,6 +945,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1449,6 +1474,7 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1553,6 +1579,7 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1649,6 +1676,15 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1792,14 +1828,25 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -3643,6 +3690,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
+2
View File
@@ -34,6 +34,8 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+583
View File
@@ -0,0 +1,583 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
>
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
> the mods decide moderation matters). The council can overturn this amendment by poll.
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
### 2.1 Three kinds of action
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
| Kind | What | How |
| --- | --- | --- |
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
Free actions, for example:
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
- a moderator gives or removes 🤝 Support, through Kira;
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
unbans;
- the Team merges and labels;
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
- a lead pins, renames or creates threads in their group's channels;
- Kira's automatic actions.
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
within 24 hours of a result.
### 2.2 Who may do what
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
council matter.
| Post | Decides | Discord | Official server | GitHub |
| --- | --- | --- | --- | --- |
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
posts as a list of clicks for a named person.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
group's elections and decisions (section 5.3).
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 Elections
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
for a recall.
| Post | Who votes | Passes with | Who can start it |
| --- | --- | --- | --- |
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
and has at least one merged pull request or review with the group's label in the last 60 days.
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
the voters judge the rest.
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
runs. Kira shows how many voted, never who. It closes early once the result can't change.
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
rest as clicks for a named person. Only the result is published, never the ballots.
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
majority, without the people involved) decides; if it passes, Kira undoes the result.
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
#### Hard cases
| Case | What happens |
| --- | --- |
| Fewer than 3 can vote | The council decides by poll |
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
| The council is already voting on it | No election starts for the same person and post |
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
#### Reminders, not nominations
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
lists privately the people who meet a floor. These are suggestions: only people start elections.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
floor and is nominated within its first 12 hours, both go on the same ballot.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- giving and removing the group's helper role, through Kira (a free action);
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
decision, any council member can call a council poll, and a simple majority overturns it.
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
involved don't vote, and the council's veto applies. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
any other, and the mods can reverse it by vote.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
and body votes are posted there too; for elections, only the result.
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
+2
View File
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## Project status
**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 table below says what the harnesses compare.
+26
View File
@@ -358,3 +358,29 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
## PCG3D hash
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
licensed under MIT:
```
Copyright 2020 Mark Jarzynski & Marc Olano
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
+4
View File
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+95 -2
View File
@@ -70,6 +70,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
one ACK per completed batch; use explore for a controlled pacing test
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
@@ -171,6 +173,8 @@ struct Options {
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
ack_rate: Option<f32>,
no_read: bool,
no_loaded: bool,
height: f64,
@@ -209,6 +213,7 @@ fn options() -> Result<Options, String> {
no_swing: false,
crits: false,
flood: 100,
ack_rate: None,
no_read: false,
no_loaded: false,
height: 24.0,
@@ -266,6 +271,13 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--ack-mode" => {
o.ack_rate = match value.as_str() {
"vanilla" => None,
"64" => Some(64.0),
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
};
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -950,6 +962,7 @@ fn read(
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
ack_rate: Option<f32>,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
@@ -1035,7 +1048,8 @@ fn read(
batch.finished(r.varint().unwrap_or(0));
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: batch.desired_chunks_per_tick(),
chunks_per_tick: ack_rate
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
})
.encode(),
)
@@ -1139,7 +1153,8 @@ fn run_bot(
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
let ack_rate = o.ack_rate;
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1519,6 +1534,7 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
@@ -1584,6 +1600,83 @@ mod tests {
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
/// Exercise the production reader over framed connections. Pings are barriers
/// so extra ACKs are observable without sleeps or timing guesses.
fn check_chunk_acks(ack_rate: Option<f32>) {
use horizon_protocol::connection::Pipe;
let (client, server) = Pipe::pair();
let (reader, writer) = Connection::local(client).split();
let mut server = Connection::local(server);
server
.reader
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let bot = Arc::new(Bot::default());
bot.online.store(true, Ordering::SeqCst);
let handle = thread::spawn(move || {
read(
reader,
Arc::new(Mutex::new(writer)),
bot,
false,
ack_rate,
None,
)
});
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"batch start must not send an ACK"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
w.varint(size)
}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
let rate = Reader::new(&body).f32().unwrap();
if let Some(fixed) = ack_rate {
assert_eq!(rate, fixed);
} else if size == 0 {
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
} else {
assert!(rate.is_finite() && rate > 0.0);
}
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"exactly one ACK per finished batch"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
}
server.writer.shutdown();
handle.join().unwrap();
}
#[test]
fn fixed_64_ack_is_sent_once_per_finished_batch() {
check_chunk_acks(Some(64.0));
}
#[test]
fn vanilla_acks_remain_once_per_finished_batch() {
check_chunk_acks(None);
}
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
@@ -514,6 +514,34 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_fallback_references_stay_bounded() {
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
// 4 KB component, which ran a client out of memory.
let bomb = |depth: usize, text: &str| {
let pattern = "%1$s".repeat(300);
let mut v = serde_json::json!({ "text": text });
for _ in 0..depth {
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
}
v
};
let started = std::time::Instant::now();
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
// One long argument referenced many times multiplies bytes, not spans.
let long = "B".repeat(64 << 10);
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
assert!(bytes <= 4 << 20, "{bytes} bytes");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
// A real fallback still formats.
let ok = serde_json::json!({
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
});
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
}
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
+19 -1
View File
@@ -529,10 +529,13 @@ impl Team {
None => t,
}
}
/// `PlayerTeam.getFormattedDisplayName`.
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
/// `displayNameStyle`, which its constructor makes the team's name on hover
/// (`ShowText`) and as insertion.
pub fn formatted_display_name(&self) -> Text {
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
click: None,
insertion: Some(self.name.clone()),
..Style::default()
});
self.apply_color(bracketed(inner))
@@ -1411,6 +1414,21 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
#[cfg(test)]
mod tests {
use super::*;
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
#[test]
fn a_teams_formatted_name_inserts_the_team_name() {
let team = Team::new("reds");
let c = team.formatted_display_name().component();
assert_eq!(c["translate"], "chat.square_brackets");
let inner = &c["with"][0];
assert_eq!(inner["text"], "reds");
assert_eq!(inner["insertion"], "reds");
assert_eq!(inner["hover_event"]["action"], "show_text");
// `{"text":"reds"}` or its short form `"reds"`: the same component.
let value = &inner["hover_event"]["value"];
assert!(value == "reds" || value["text"] == "reds", "{value}");
}
fn board() -> Scoreboard {
let mut sb = Scoreboard::default();
+70 -12
View File
@@ -282,17 +282,20 @@ impl Text {
pub fn push(&mut self, other: impl Into<Text>) {
let other = other.into();
let sibling = other.component();
let mut root = if self.spans.is_empty() && self.source.is_none() {
json!({ "text": "" })
} else {
let c = self.component();
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
json!({ "text": "", "extra": [c] })
} else {
c
}
// The tree is appended to in place: cloning all of it on every append made a component
// with N siblings (a server's chat or MOTD "extra") cost N^2.
let root = match self.source.take() {
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
None => std::sync::Arc::new(self.component()),
Some(c) => c,
};
if let Value::Object(o) = &mut root {
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
} else {
root
};
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
match o.get_mut("extra") {
Some(Value::Array(a)) => a.push(sibling),
_ => {
@@ -300,7 +303,7 @@ impl Text {
}
}
}
self.source = Some(root.into());
self.source = Some(root);
self.spans.extend(other.spans);
}
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
@@ -671,11 +674,22 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
s
}
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
/// references to its arguments, so its cost stays linear in the input; copying them here makes
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
/// `fallback` template is any text it likes.
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
/// The most text one translation expands to: one long argument referenced many times multiplies
/// bytes, not spans.
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
/// [`MAX_TRANSLATION_BYTES`].
pub fn format_translation(template: &str, args: &[Text]) -> Text {
let mut out = Text::default();
let mut bytes = 0usize;
let mut literal = String::new();
let mut next = 0usize;
let chars: Vec<char> = template.chars().collect();
@@ -725,6 +739,14 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
let Some(arg) = args.get(index) else {
return Text::literal(template);
};
bytes = bytes
.saturating_add(literal.len())
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|| bytes > MAX_TRANSLATION_BYTES
{
return Text::literal(template);
}
if !literal.is_empty() {
out.spans.push(Span {
text: std::mem::take(&mut literal),
@@ -750,6 +772,42 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
let mut styled = Text::literal("a").with_style(&Style {
bold: Some(true),
..Style::default()
});
styled.push(Text::literal("b"));
let before = styled.clone();
styled.push(Text::literal("c"));
assert_eq!(
styled.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
);
assert_eq!(
before.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
);
let mut plain = Text::default();
plain.push(Text::literal("x"));
plain.push(Text::literal("y"));
assert_eq!(
plain.component(),
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
);
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
// whole tree.
let started = std::time::Instant::now();
let mut long = Text::default();
for _ in 0..20_000 {
long.push(Text::literal("a"));
}
assert_eq!(long.spans.len(), 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn click_events_serialize_and_inherit_as_one_style_field() {
for (event, json) in [
(
+4 -2
View File
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
@@ -98,6 +98,7 @@ actions! {
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
Radial, "pad.radial", None;
}
impl Action {
+221 -18
View File
@@ -253,6 +253,17 @@ impl Activity {
/// Horizon's pad-only actions (no vanilla key mapping).
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
/// Held, opens the radial menu (`radial.rs`).
pub const RADIAL: &str = "pad.radial";
mod radial_file;
pub use radial_file::{RadialMode, RadialStick, opener_refused};
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
/// are; 2: the radial menu's opener resolved against the player's own buttons.
const BIND_LAYOUT: u32 = 2;
/// The layout from which the file, not the default, says where the opener is.
const OPENER_LAYOUT: u32 = 2;
/// Bindable actions and their default buttons: vanilla key mappings (the
/// game acts as their default key) and Horizon's pad-only actions. Start is
@@ -271,6 +282,7 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.togglePerspective", Some(Button::Select)),
("key.pickItem", Some(Button::DPadLeft)),
("key.chat", Some(Button::DPadUp)),
(RADIAL, Some(Button::DPadRight)),
("key.advancements", None),
("key.playerlist", None),
("key.command", None),
@@ -303,11 +315,14 @@ pub enum Route {
HotbarStep {
next: bool,
},
Radial,
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Fullscreen,
/// F3's debug screen, shown or hidden as F3 alone does.
DebugOverlay,
/// Does nothing by itself: what reads it asks whether the button is held
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
Held,
@@ -320,10 +335,12 @@ pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
RADIAL => Some(Route::Radial),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
"key.fullscreen" => Some(Route::Fullscreen),
"key.debug.overlay" => Some(Route::DebugOverlay),
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
@@ -334,12 +351,16 @@ pub fn route(action: &str) -> Option<Route> {
}
}
/// The radial menu's opener, as the binds list and the gameplay guide name it.
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
match action {
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
HOTBAR_NEXT => "Hotbar: Next Slot",
RADIAL => RADIAL_CAPTION,
other => other,
}
}
@@ -654,6 +675,21 @@ pub struct PadSettings {
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
/// The radial menu's pages.
pub radial: crate::radial::Pages,
/// Whether the radial menu's opener is held or pressed once.
pub radial_mode: RadialMode,
/// The stick that selects in it.
pub radial_stick: RadialStick,
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
/// button it doesn't know), kept as they were so a save here loses nothing.
extra: Vec<(String, String)>,
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
/// kept so that one doesn't resolve its file again as older than it is.
bind_layout: u32,
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
/// With row and back gives each stick's own opener again.
openers: radial_file::StickOpeners,
}
impl Default for PadSettings {
fn default() -> Self {
@@ -680,6 +716,12 @@ impl Default for PadSettings {
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
radial: Default::default(),
radial_mode: RadialMode::Hold,
radial_stick: RadialStick::Right,
extra: Vec::new(),
bind_layout: BIND_LAYOUT,
openers: radial_file::StickOpeners::default(),
}
}
}
@@ -706,6 +748,7 @@ impl PadSettings {
}
pub fn parse(text: &str) -> Self {
let mut s = Self::default();
let mut saved_opener = None;
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -754,22 +797,85 @@ impl PadSettings {
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
"radialMode" => s.radial_mode = RadialMode::parse(value),
"radialStick" => s.radial_stick = RadialStick::parse(value),
// Read after the loop, from the whole text.
"bindLayout" => {}
// The radial menu's pages (`radial::Pages::parse`).
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
if let Some(button) = button {
s.bind(action, button);
}
let known = other
.strip_prefix("bind_")
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
match (known, button) {
// The opener once the selecting stick is known.
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
(Some(action), Some(button)) => s.bind(action, button),
_ => s.extra.push((other.to_owned(), value.to_owned())),
}
}
}
}
// 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:")) {
// An opener on a button it can't be with this stick reads as never saved.
let refused_opener = match saved_opener {
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
Some(button) => {
s.bind(RADIAL, button);
false
}
None => false,
};
if let Some(pages) = crate::radial::Pages::parse(
text.lines()
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim(), value.trim())),
) {
s.radial = pages;
}
let lines = || text.lines().map(str::trim);
let saved = |action: &str| {
let prefix = format!("bind_{action}:");
lines().any(|line| line.starts_with(&prefix))
};
// The layout the file was written in; none in a file from before these defaults.
let layout = lines()
.find_map(|line| line.strip_prefix("bindLayout:"))
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
// Once, for a file from before the radial menu's opener, or one with the opener on a
// button it can no longer be: a button the player bound by hand stays theirs, and the
// opener's default doesn't take it. From then on the file says where the opener is, and
// a conflict made since stays shown in the binds list.
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
let default_opener = ACTIONS
.iter()
.find(|(action, _)| *action == RADIAL)
.and_then(|(_, button)| *button);
let taken = s.bindings.iter().any(|(action, button)| {
*action != RADIAL && button.is_some() && *button == default_opener
});
if taken {
s.bindings.insert(RADIAL, None);
}
}
// The default too can be under the selecting thumb (D-pad Right, the left stick
// selecting): no button opens the menu until the player picks one.
if s.binding(RADIAL)
.is_some_and(|b| opener_refused(s.radial_stick, b))
{
s.bindings.insert(RADIAL, None);
}
// A file from before these defaults: its player's buttons stay where they were, and
// nothing they saved shares a button with the radial menu's opener. They can bind one
// in the binds list.
if !text.is_empty() && layout.is_none() {
s.bindings.entry(RADIAL).or_insert(None);
for (action, button) in [
("key.drop", Button::East),
("key.togglePerspective", Button::DPadUp),
@@ -778,10 +884,7 @@ impl PadSettings {
("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}:")))
{
if !saved(action) {
s.bindings.entry(action).or_insert(Some(button));
}
}
@@ -822,7 +925,25 @@ impl PadSettings {
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text.push_str(&format!(
"radialMode:{}\nradialStick:{}\n",
self.radial_mode.name(),
self.radial_stick.name()
));
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
for (key, value) in &self.extra {
// A button this game didn't know, for an action the player has bound here since.
let rebound = key
.strip_prefix("bind_")
.is_some_and(|action| self.bindings.contains_key(action));
if !rebound {
text.push_str(&format!("{key}:{value}\n"));
}
}
// The default pages aren't written: they can improve for a player who never changed them.
if self.radial != crate::radial::Pages::default() {
text.push_str(&self.radial.serialize());
}
text
}
pub fn default_of(action: &str) -> Option<Button> {
@@ -835,11 +956,15 @@ impl PadSettings {
None => Self::default_of(action),
}
}
/// Bind an action (`None` unbinds); unknown actions are ignored.
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
/// (`can_bind`), changes nothing.
pub fn bind(&mut self, action: &str, button: Option<Button>) {
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
return;
};
if button.is_some_and(|b| !self.can_bind(action, b)) {
return;
}
if button == *default {
self.bindings.remove(name);
} else {
@@ -1440,6 +1565,24 @@ pub enum Rumble {
radius: f32,
},
Totem,
/// The radial menu's stick reached a slot that would run: a tick.
RadialSlot,
/// The radial menu's page turned: a shorter tick.
RadialPage,
/// The page turn went round an end: a second tick after the first.
RadialWrap,
/// A release ran nothing after something had been selected: a soft buzz.
RadialNothing,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
use crate::input_source::Feedback;
match event {
Feedback::BlockBroken => Rumble::BlockBroken,
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
Feedback::Totem => Rumble::Totem,
}
}
}
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
/// long it lasts.
@@ -1449,6 +1592,7 @@ pub struct Pulse {
pub weak: f32,
pub millis: u32,
}
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
impl Rumble {
pub fn name(self) -> &'static str {
match self {
@@ -1456,6 +1600,10 @@ impl Rumble {
Rumble::BlockBroken => "block",
Rumble::Explosion { .. } => "explosion",
Rumble::Totem => "totem",
Rumble::RadialSlot => "radial",
Rumble::RadialPage => "radial_page",
Rumble::RadialWrap => "radial_wrap",
Rumble::RadialNothing => "radial_nothing",
}
}
/// The pulse, or `None` when it is too faint to feel.
@@ -1482,6 +1630,9 @@ impl Rumble {
millis: 350,
})
}
Rumble::RadialSlot => Some(RADIAL_TICK),
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
Rumble::RadialNothing => Some(RADIAL_NOTHING),
Rumble::Totem => Some(Pulse {
strong: 0.7,
weak: 0.7,
@@ -1503,6 +1654,10 @@ impl Rumble {
};
match self {
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
Rumble::RadialSlot
| Rumble::RadialPage
| Rumble::RadialWrap
| Rumble::RadialNothing => vec![p],
Rumble::Hurt(_) => {
let tail = p.millis.saturating_sub(50) / 2;
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
@@ -1863,7 +2018,55 @@ bind_key.hotbar.2:north
let odd = PadSettings::parse(
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
);
assert_eq!(odd, PadSettings::default());
assert_eq!(
odd.look_sensitivity,
PadSettings::default().look_sensitivity
);
assert_eq!(odd.binding("key.jump"), Some(Button::South));
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
}
#[test]
fn lines_this_game_doesnt_read_survive_a_save() {
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
let text =
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
let s = PadSettings::parse(text);
assert_eq!(
s.binding("key.jump"),
Some(Button::South),
"the default meanwhile"
);
let saved = s.serialize();
for line in [
"hapticStrength:0.4\n",
"bind_horizon:dash:south\n",
"bind_key.jump:paddle1\n",
] {
assert!(saved.contains(line), "{line} lost from\n{saved}");
}
assert_eq!(PadSettings::parse(&saved), s);
// Bound here since, the action's own line is the one written.
let mut s = s;
s.bind("key.jump", Some(Button::West));
let saved = s.serialize();
assert!(!saved.contains("paddle1"), "{saved}");
assert_eq!(
PadSettings::parse(&saved).binding("key.jump"),
Some(Button::West)
);
}
#[test]
fn a_newer_bind_layout_is_kept() {
let newer = BIND_LAYOUT + 1;
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert!(
s.serialize()
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
);
}
#[test]
@@ -0,0 +1,354 @@
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
use super::{Button, PadSettings, RADIAL};
/// The buttons under the right thumb, which steers the right stick.
const RIGHT_THUMB: [Button; 4] = [
Button::South,
Button::West,
Button::North,
Button::RightStick,
];
/// The buttons under the left thumb, which steers the left stick.
const LEFT_THUMB: [Button; 5] = [
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::LeftStick,
];
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
/// with a bumper for the opener the other bumper turns the pages.
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
button == Button::East
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
}
/// How the radial menu's opener holds it open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialMode {
/// Held: letting go runs what is selected.
#[default]
Hold,
/// A press opens it; A or the opener again runs what is selected, B closes it.
Toggle,
}
impl RadialMode {
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
pub fn name(self) -> &'static str {
match self {
Self::Hold => "hold",
Self::Toggle => "toggle",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == value)
.unwrap_or_default()
}
}
/// The stick that selects in the radial menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialStick {
#[default]
Right,
Left,
/// The last stick to lean past the select tilt.
Either,
}
impl RadialStick {
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
pub fn name(self) -> &'static str {
match self {
Self::Right => "right",
Self::Left => "left",
Self::Either => "either",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|stick| stick.name() == value)
.unwrap_or_default()
}
}
/// The opener each stick had, for this session: what the player stepped through, not a setting,
/// so it is neither saved nor compared.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct StickOpeners([Option<Button>; 3]);
impl PartialEq for StickOpeners {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl StickOpeners {
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
let at = RadialStick::ALL
.iter()
.position(|s| *s == stick)
.unwrap_or(0);
&mut self.0[at]
}
}
impl PadSettings {
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
/// a thumb that selects, or on B (`opener_refused`).
pub fn can_bind(&self, action: &str, button: Button) -> bool {
action != RADIAL || !opener_refused(self.radial_stick, button)
}
/// The stick the radial menu selects with. An opener under the thumb that now selects is
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
/// stick had before in this session, or else its default where nothing else has it (D-pad
/// Right, back on the right stick).
pub fn set_radial_stick(&mut self, stick: RadialStick) {
let was = self.radial_stick;
*self.openers.slot(was) = self.binding(RADIAL);
self.radial_stick = stick;
if self
.binding(RADIAL)
.is_some_and(|b| opener_refused(stick, b))
{
self.bindings.insert(RADIAL, None);
}
if self.binding(RADIAL).is_some() {
return;
}
// The player's own, shared or not: they put it there.
if let Some(own) = self
.openers
.slot(stick)
.filter(|b| !opener_refused(stick, *b))
{
self.bind(RADIAL, Some(own));
return;
}
let default = Self::default_of(RADIAL);
let free =
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
if free {
self.bindings.remove(RADIAL);
}
}
}
#[cfg(test)]
mod tests {
use super::super::Rumble;
use super::*;
#[test]
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
for s in [PadSettings::default(), PadSettings::parse("")] {
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
assert!(!s.conflicts(RADIAL));
}
let s = PadSettings::parse(&PadSettings::default().serialize());
assert_eq!(s, PadSettings::default());
}
#[test]
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding(RADIAL), None);
assert!(!s.conflicts("key.playerlist"));
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn a_saved_opener_stays_where_it_was_saved() {
let s =
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
let mut s = PadSettings::default();
s.bind("key.playerlist", Some(Button::DPadRight));
let text = s.serialize();
assert!(text.contains("bindLayout:2"));
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
assert!(read.conflicts(RADIAL));
assert_eq!(read, s);
// The same, hand-written with spaces around the lines.
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_layout_one_file_resolves_the_opener_once() {
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
let text = s.serialize();
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
assert_eq!(PadSettings::parse(&text), s);
// Without a conflict the opener keeps its default.
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// A line with leading spaces is still a saved opener.
let s =
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
use Button as B;
let face = [B::South, B::West, B::North, B::RightStick];
let dpad = [
B::DPadUp,
B::DPadDown,
B::DPadLeft,
B::DPadRight,
B::LeftStick,
];
for stick in RadialStick::ALL {
let mut s = PadSettings::default();
s.set_radial_stick(stick);
for button in Button::ALL {
let refused = button == B::East
|| (stick != RadialStick::Left && face.contains(&button))
|| (stick != RadialStick::Right && dpad.contains(&button));
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
// Other actions still take any of them.
assert!(s.can_bind("key.chat", button));
}
// A bumper is always free, and stays one through the file.
s.bind(RADIAL, Some(B::LeftBumper));
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
}
// A file with a refused opener reads as never saved: the default, unless taken.
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
let taken = format!("{text}bind_key.playerlist:right\n");
assert_eq!(
PadSettings::parse(&taken).binding(RADIAL),
None,
"{button:?}"
);
}
// Read with the stick it was saved for, wherever the stick's line is.
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
assert_eq!(left.binding(RADIAL), Some(B::North));
}
#[test]
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
assert_eq!(
s.binding(RADIAL),
None,
"D-pad Right is under the left thumb"
);
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// An opener the player picked that both sticks allow stays.
s.bind(RADIAL, Some(Button::RightBumper));
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
}
#[test]
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
s.bind(RADIAL, Some(Button::North));
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// And Left again has its Y back, shared with Inventory as the player left it.
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), Some(Button::North));
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
let mut s = PadSettings::default();
s.bind(RADIAL, Some(Button::DPadLeft));
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
}
#[test]
fn a_file_from_before_the_radial_menu_binds_no_opener() {
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
assert_eq!(s.binding(RADIAL), None);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
let tick = Rumble::RadialSlot.pulse().unwrap();
assert!((40..=50).contains(&tick.millis), "{tick:?}");
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
let page = Rumble::RadialPage.pulse().unwrap();
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
let nothing = Rumble::RadialNothing.pulse().unwrap();
assert!(
nothing.strong > 0.0 && nothing.millis > tick.millis,
"{nothing:?}"
);
assert!(
nothing.strong.max(nothing.weak) < 0.3,
"a soft buzz: {nothing:?}"
);
}
#[test]
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
let s = PadSettings::default();
assert_eq!(
(s.radial_mode, s.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
for mode in RadialMode::ALL {
for stick in RadialStick::ALL {
let mut s = PadSettings {
radial_mode: mode,
..PadSettings::default()
};
s.set_radial_stick(stick);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
}
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
assert_eq!(
(read.radial_mode, read.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
}
#[test]
fn radial_pages_survive_the_settings_file() {
let mut s = PadSettings::default();
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
s.radial =
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
let read = PadSettings::parse(&s.serialize());
assert_eq!(read.radial, s.radial);
assert_ne!(read.radial, crate::radial::Pages::default());
}
}
@@ -141,8 +141,13 @@ impl Guide {
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
// Nothing else in play names the radial menu.
GuideDetail::Basic => Guide {
left: Vec::new(),
left: self
.left
.into_iter()
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
.collect(),
right: self.right,
},
GuideDetail::Off => Guide {
@@ -178,6 +183,8 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
// Nothing else in play says the menu is there; it opens for a spectator too.
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
]
.into_iter()
.flatten()
@@ -566,6 +573,7 @@ mod tests {
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
]
);
}
@@ -736,12 +744,12 @@ mod tests {
gliding: true,
..Facts::default()
}),
["key.inventory"]
["key.inventory", crate::gamepad::RADIAL_CAPTION]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
let facts = Facts {
spectator: true,
flying: true,
@@ -765,7 +773,8 @@ mod tests {
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
"horizon.controller.guide.fly_down",
crate::gamepad::RADIAL_CAPTION
]
);
}
@@ -0,0 +1,15 @@
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
//! types, never a controller's; the controller code answers.
/// Something that happened to the player that a device may let them feel (a controller's
/// vibration). The device decides how, or whether.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Feedback {
/// The player broke a block.
BlockBroken,
/// An explosion this far away (blocks) with this power.
Explosion { distance: f64, radius: f32 },
/// A totem of undying saved the player.
Totem,
}
+2
View File
@@ -9,11 +9,13 @@ pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod input_source;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod radial;
pub mod snap;
pub mod window;
File diff suppressed because it is too large. Load diff
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ 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 {
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -739,7 +760,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
+6 -7
View File
@@ -27,6 +27,9 @@ horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -70,7 +73,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
@@ -109,17 +111,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
@@ -131,6 +128,8 @@ windows-segment-heap = []
workspace = true
[dev-dependencies]
# ui_theme's unit tests draw text in the design system's fonts.
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
+2 -1
View File
@@ -29,7 +29,6 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
@@ -85,5 +84,7 @@ horizon-extras = []
workspace = true
[dev-dependencies]
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
@@ -0,0 +1,158 @@
//! Audio occlusion, attenuation and mixing benchmark.
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
use horizon_client_render::{
audio::Audio,
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
};
use std::{path::Path, time::Instant};
fn main() {
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
let mut audio = Audio::silent(Path::new("/nonexistent"));
audio.engine.loaded = true;
audio.backend_live_for_bench();
audio.record_heard = false;
// Register a sound event with attenuation distance 16
audio.engine.registry.add_file(
"minecraft",
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
&|_, _| true,
);
// Insert 1 second of audio into cache (48000 mono samples)
let samples: Vec<f32> = (0..48_000)
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
.collect();
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
// 1. Benchmark set_listener (called every client render frame)
let listener_iterations = 200_000;
let start = Instant::now();
for i in 0..listener_iterations {
let angle = (i as f32) * 0.01;
audio.set_listener(
[
100.0 + (angle.cos() as f64) * 5.0,
64.0,
200.0 + (angle.sin() as f64) * 5.0,
],
angle,
0.0,
);
}
let listener_elapsed = start.elapsed();
println!(
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
listener_elapsed,
listener_iterations,
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
);
// 1b. Benchmark set_listener stationary (lock elimination early-out)
let stationary_iterations = 200_000;
let start = Instant::now();
for _ in 0..stationary_iterations {
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
}
let stationary_elapsed = start.elapsed();
println!(
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
stationary_elapsed,
stationary_iterations,
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
);
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
// 64 sounds within attenuation distance (<16 blocks)
// 64 sounds outside attenuation distance (>16 blocks)
for i in 0..128 {
let dist = if i < 64 {
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
} else {
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
};
let pos = [100.0 + dist, 64.0, 200.0];
let instance = SoundInstance::new(
"minecraft:bench.loop",
crate_sound_source(i),
1.0,
1.0,
horizon_core::random::LegacyRandom::new(i as i64),
true, // looping
0,
horizon_client_render::sound_engine::Attenuation::Linear,
pos,
false,
);
audio.play_instance(instance, false);
}
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
let mut buffer = vec![0.0f32; 1024 * 2];
let mix_iterations = 5_000;
let start = Instant::now();
for _ in 0..mix_iterations {
audio.mix_buffer(&mut buffer, 2, 48_000);
}
let mix_elapsed = start.elapsed();
println!(
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
mix_elapsed,
mix_iterations,
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
);
// 4. Benchmark SoundEngine::tick with ticking mob sounds
let mut mobs = Vec::new();
for i in 0..64 {
mobs.push(MobSound {
id: 2000 + i,
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
speed: 0.25,
baby: false,
angry: i % 2 == 0,
silent: false,
attack_scale: 0.5,
sniffer_digging: false,
});
}
let tick_ctx = TickContext {
player_underwater: false,
player_removed: false,
vehicle: None,
carts: Vec::new(),
mobs: mobs.clone(),
frozen: false,
player_position: [100.0, 64.0, 200.0],
player_speed_sqr: 0.1,
player_fall_flying: false,
ridden: None,
camera_position: [100.0, 64.0, 200.0],
};
let tick_iterations = 10_000;
let start = Instant::now();
for _ in 0..tick_iterations {
audio.tick(false, &tick_ctx);
}
let tick_elapsed = start.elapsed();
println!(
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
tick_elapsed,
tick_iterations,
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
);
}
fn crate_sound_source(i: u32) -> usize {
match i % 6 {
0 => 0, // master
1 => 1, // music
2 => 2, // records
3 => 3, // weather
4 => 4, // blocks
_ => 5, // hostile
}
}
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
+58 -2
View File
@@ -6,7 +6,12 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
use std::{
collections::BTreeSet,
io,
path::Path,
sync::{Arc, Mutex},
};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -182,6 +187,23 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
/// Every block registry [`Assets::load`] has handed out, one per distinct
/// content. Callers keep `&'static` references, so a registry is never freed;
/// loading equal data again (tests load the same root once per fixture)
/// returns the one already leaked instead of leaking another (about 11 MB for
/// vanilla).
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
/// `registry` leaked, or the equal one leaked before it.
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
return same;
}
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
leaked.push(registry);
registry
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
@@ -256,7 +278,7 @@ impl Assets {
// Chunks hold every state of this registry: content mods can take it
// past vanilla's 16-bit global palette.
crate::chunk::set_block_states(registry.len());
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
let registry = intern_registry(registry);
Ok(Self {
registry,
properties,
@@ -320,6 +342,16 @@ impl RenderAssets {
}
None => crate::model_library::ParsedModels::default(),
};
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
// layer of its own, decided before the models insert their textures.
let ctm_rules = if draws {
horizon_optifine::ctm::load(root)
} else {
Vec::new()
};
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
&ctm_rules,
));
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
@@ -359,6 +391,7 @@ impl RenderAssets {
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
ctm_rules,
registry,
&mut sprites,
|state, face| {
@@ -496,6 +529,29 @@ mod tests {
));
}
#[test]
fn equal_registries_are_leaked_once() {
use horizon_world::registry::BlockRegistry;
let registry = |extra: &str| {
let facts = format!(
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
["air",true,false,"","minecraft:air"],
["{extra}",false,false,"","fixture:{extra}"]
]}}"#
);
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
};
let first = super::intern_registry(registry("interned_once"));
let again = super::intern_registry(registry("interned_once"));
let other = super::intern_registry(registry("interned_other"));
assert!(std::ptr::eq(first, again), "equal content leaked twice");
assert!(!std::ptr::eq(first, other), "different content shared");
assert_eq!(
other.default_state("fixture:interned_other"),
Some(horizon_world::registry::BlockStateId(1))
);
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
+167 -53
View File
@@ -478,6 +478,7 @@ pub struct Audio {
pub record_heard: bool,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub listener_pitch: f32,
pub master: f32,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.gain = volume;
c.pitch = pitch;
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
}
}
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
if let Ok(m) = self.mixer.lock() {
for &(h, c) in live {
if m.channels
.get(&c)
.is_none_or(|ch| ch.state == State::Stopped)
{
stopped.push(h);
}
}
} else {
stopped.extend(live.iter().map(|&(h, _)| h));
}
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
stereo_gains_with_length(right, up, forward, length)
}
#[inline]
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
if length <= f32::EPSILON {
return stereo_gains(0.0, 0.0, 1.0);
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
}
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
let (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let (yaw_sin, yaw_cos) = yaw.sin_cos();
let (pitch_sin, pitch_cos) = pitch.sin_cos();
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
for (id, c) in &mut mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let camera = if !shared {
let cam = &mixer.cameras[0];
if c.relative || cam.2 == c.world {
Some((cam.0, cam.1))
} else {
None
}
} else {
mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
.map(|camera| (camera.0, camera.1))
};
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
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)
* if direct {
1.0
} else {
distance_gain(c.linear, distance)
let d_sqr = length_sq(d);
let (gain, [left, right]) = if camera.is_none() {
(0.0, [0.0, 0.0])
} else if direct {
let gain = c.gain.clamp(0.0, 1.0);
(gain, [gain, gain])
} else {
let mut zero = false;
if let Some(max) = c.linear {
let max_d = max as f64;
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
zero = true;
}
} else {
0.0
};
let [left, right] = if direct {
[gain, gain]
} else {
// 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 zero {
(0.0, [0.0, 0.0])
} else {
let distance = d_sqr.sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let [mut left, mut right] = if c.relative {
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
} else {
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
};
if shared {
let middle = (left + right) * 0.5;
left = middle + (left - middle) * 0.5;
right = middle + (right - middle) * 0.5;
}
(gain, [left * gain, right * gain])
}
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
let inaudible = if channels >= 2 {
left == 0.0 && right == 0.0
} else {
gain == 0.0
};
if inaudible {
let buffer_frames = data.len() / channels;
// As the loop below: a sound of one frame or none stops, looping or not.
if !c.looping || frames <= 1 {
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
// reaches the end, as in the loop below.
let needed = frames as f64 - 1.0 - c.position;
if needed <= 0.0 {
c.state = State::Stopped;
} else if step > 0.0 {
let steps_to_stop = needed / step;
if (buffer_frames as f64) >= steps_to_stop {
c.state = State::Stopped;
} else {
c.position += step * (buffer_frames as f64);
}
}
} else {
for _ in 0..buffer_frames {
let i = c.position as usize;
if i + 1 >= frames {
c.position = 0.0;
}
c.position += step;
}
}
continue;
}
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
let t = (c.position - i as f64) as f32;
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;
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1412,6 +1482,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: 1.0,
random: crate::random_source::create(),
source_override: None,
@@ -1535,8 +1606,12 @@ impl Audio {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
return;
}
self.listener = position;
self.listener_yaw = yaw;
self.listener_pitch = pitch;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
@@ -1576,6 +1651,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: self.master,
random: crate::random_source::create(),
source_override: None,
@@ -2109,6 +2185,25 @@ impl Audio {
})
.collect()
}
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
if let Ok(mut m) = self.backend.mixer.lock() {
mix(&mut m, data, channels, rate);
}
}
#[doc(hidden)]
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
if let Ok(mut c) = self.backend.cache.lock() {
c.insert(
key.to_owned(),
Some((std::sync::Arc::new(samples), rate, channels)),
);
}
}
#[doc(hidden)]
pub fn backend_live_for_bench(&mut self) {
self.backend.live = true;
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
@@ -2613,6 +2708,25 @@ mod tests {
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
/// A sound of one frame or none, heard or not, looping or not, stops at its first
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
#[test]
fn sounds_too_short_to_mix_stop_even_unheard() {
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
let mut mixer = Mixer::default();
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
source.gain = 0.0;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(
mixer.channels[&1].state,
State::Stopped,
"{samples:?} {looping}"
);
}
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
+61 -1
View File
@@ -7,7 +7,7 @@ use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::Arc,
sync::{Arc, OnceLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -398,7 +398,37 @@ impl Biomes {
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
/// The biome at `id`, clamped to the last entry when `id` is past the
/// table (a stale column from an older registry keeps its id).
///
/// When the table is empty (a resource pack with no biome files), returns
/// a shared default plains biome instead of panicking on `list.len() - 1`.
pub fn get(&self, id: u16) -> &BiomeInfo {
if self.list.is_empty() {
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
return DEFAULT.get_or_init(|| BiomeInfo {
spawns: HashMap::new(),
spawn_costs: HashMap::new(),
creature_probability: 0.0,
increased_fire_burnout: false,
name: "minecraft:plains".to_owned(),
temperature: 0.8,
frozen: false,
downfall: 0.4,
precipitation: false,
grass: 0x7f_c0_55,
modifier: GrassModifier::None,
foliage: 0x6a_ab_37,
dry_foliage: 0x79_75_54,
water: 0x3f_76_e4,
sky: None,
fog: None,
water_fog: 0x3f_76_e4,
ambient_particles: Vec::new(),
features: Vec::new(),
attributes: Default::default(),
});
}
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
@@ -795,4 +825,34 @@ mod blend_tests {
}
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
}
/// An empty biome table (a resource pack with no biome files) must not
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
#[test]
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
use std::sync::Arc;
let empty = Biomes {
builtin: Arc::new(Vec::new()),
index: HashMap::new(),
list: Vec::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
}),
packs: BTreeMap::new(),
};
// Any id returns the shared default plains biome, no panic.
let b = empty.get(0);
assert_eq!(b.name, "minecraft:plains");
let b = empty.get(65535);
assert_eq!(b.name, "minecraft:plains");
// Colours and temperature come from the default.
assert_eq!(b.temperature, 0.8);
assert_eq!(b.grass, 0x7f_c0_55);
assert_eq!(b.foliage, 0x6a_ab_37);
assert_eq!(b.water, 0x3f_76_e4);
}
}
+100
View File
@@ -84,6 +84,71 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
)
.collect()
}
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
use horizon_client_core::nbt_value::{number, text};
use horizon_world::nbt::Tag;
let mut effects: Vec<_> = stack
.potion()
.and_then(potion)
.map_or(&[][..], |p| p.effects)
.iter()
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
.collect();
if let Some((_, Tag::List(custom))) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
{
for e in custom {
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
continue;
};
let mut instance = crate::effects::EffectInstance::new(
def.id,
number(e.get("duration")).unwrap_or(0) as i32,
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
);
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
instance.visible = number(e.get("show_particles")) != Some(0);
effects.push(instance);
}
}
effects
}
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
/// visible base and custom effects, with their amplifier weights.
pub fn contents_color(stack: &ItemStack) -> u32 {
use horizon_client_core::nbt_value::number;
if let Some(color) = stack
.data()
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
.and_then(|(_, v)| number(Some(v)))
{
return color as u32;
}
0xFF00_0000
| horizon_client_core::potions::effects_color(
contents_instances(stack)
.iter()
.filter(|e| e.visible)
.map(|e| (e.id, e.amplifier)),
)
}
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
/// nonempty, even when there is no registered potion or custom effect.
pub fn contents_empty(stack: &ItemStack) -> bool {
use horizon_world::nbt::Tag;
stack.potion().is_none()
&& stack.data().is_none_or(|d| {
d.potion_extra
.iter()
.all(|(key, value)| match key.as_str() {
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
"custom_color" | "custom_name" => false,
_ => true,
})
})
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
@@ -706,4 +771,39 @@ mod tests {
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
#[test]
fn custom_contents_color_and_empty_semantics() {
use horizon_world::nbt::Tag;
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
assert!(contents_empty(&stack));
stack
.data_mut()
.potion_extra
.push(("custom_effects".into(), Tag::List(Vec::new())));
assert!(contents_empty(&stack));
stack.data_mut().potion_extra = vec![(
"custom_effects".into(),
Tag::List(vec![
Tag::compound([
("id", Tag::String("minecraft:glowing".into())),
("amplifier", Tag::Int(1)),
]),
Tag::compound([
("id", Tag::String("minecraft:poison".into())),
("show_particles", Tag::Byte(0)),
]),
]),
)];
assert!(!contents_empty(&stack));
// Glowing's colour; hidden poison contributes no weight.
assert_eq!(contents_color(&stack), 0xFF94_A061);
stack
.data_mut()
.potion_extra
.push(("custom_color".into(), Tag::Int(123456)));
assert_eq!(contents_color(&stack), 123456);
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
assert!(!contents_empty(&stack));
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
}
}
+10 -1
View File
@@ -1974,7 +1974,11 @@ impl ChatScreen {
return false;
}
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
// Round instead of truncating: touchpad/PixelDelta produces fractional
// amounts and truncation drops movement in one direction (#14338).
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
.round()
.clamp(0.0, max as f64) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input. The
@@ -2086,6 +2090,11 @@ impl ChatScreen {
) {
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
// SuggestionsList.extractRenderState: the whole list asks for the
// pointing hand while the pointer is inside its rectangle.
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
}
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
let moved = l.last_mouse != mouse;
@@ -1066,3 +1066,32 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
.all(|s| s.style.color.is_none())
);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
fn the_suggestion_list_asks_for_the_pointing_hand() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/");
let rect = screen
.suggestions
.as_ref()
.expect("the root commands are a suggestion list")
.rect;
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
}
@@ -539,6 +539,7 @@ pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
pub reload_particle: bool,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+48 -3
View File
@@ -124,15 +124,25 @@ fn sprite_name(resource: &str) -> String {
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
/// [`Sprites::keep_apart`]: before the models insert theirs.
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
(rules.iter())
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
.map(|t| sprite_name(t))
.collect()
}
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
/// by tile.
pub fn load(
root: &Path,
mut rules: Vec<Rule>,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules with a custom name load no tiles: section meshes see no block entity.
rules.retain(|r| r.name.is_none());
// Rule indices are `u16`.
@@ -1270,6 +1280,41 @@ mod tests {
assert!(!ctm.stacked_pane_hides(3, 3, post));
}
#[test]
fn rules_name_the_sprites_they_match() {
// `matchTiles` among the textures and beside the file (a chain onto another rule's
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
// under.
let dir = "assets/minecraft/optifine/ctm/pack/crops";
let rule = |file: &str, text: &str| {
Rule::parse(
&format!("{dir}/{file}"),
&horizon_optifine::properties::Properties::parse(text),
)
.unwrap()
.unwrap()
};
let rules = [
rule(
"1.properties",
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
),
rule(
"2.properties",
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
connectTiles=textures/block/stone.png\ntiles=0-16",
),
];
assert_eq!(
ConnectedTextures::matched_sprites(&rules),
[
format!("{dir}/19.png"),
"minecraft:block/extra/placeholder".to_owned(),
"minecraft:block/stone".to_owned(),
]
);
}
#[test]
fn sprite_names() {
assert_eq!(
@@ -57,6 +57,8 @@ pub enum Item {
/// Wait for a button to bind to this action.
Bind(&'static str),
ResetBinds,
/// Opens the radial menu's pages (`radial_editor`).
Radial,
Info(Info),
}
@@ -106,6 +108,15 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
}
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
},
Opt::PadRadialMode => match pad.radial_mode {
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
},
Opt::PadRadialStick => match pad.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
},
_ => return None,
})
}
@@ -123,6 +134,11 @@ fn info(info: Info, text: &'static str) -> Setting {
}
}
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
const RADIAL_GROUP: &str = "Radial Menu";
/// The row that opens the radial menu's pages.
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
/// Shown on a binding's row until it is focused, and in the search.
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
@@ -201,6 +217,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
RADIAL_GROUP,
vec![
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
],
),
(
"Menus",
vec![
@@ -231,6 +254,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
(
Page::Bindings,
vec![
(
RADIAL_GROUP,
vec![Setting {
item: Item::Radial,
text: "Named pages of eight actions, held open with a button.",
}],
),
("Actions", binds),
(
"Reset",
@@ -437,6 +467,7 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
Row::new(item, name, value).about(about)
}
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
Item::Info(which) => {
let (name, value) = context.read(which);
Row::new(item, lang::get(name), Value::Read(value)).about(about)
@@ -496,6 +527,7 @@ impl Screen for ControllerScreen {
s.controller.reset_bindings();
vec![Output::Event(Event::Changed)]
}
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
Item::Bind(_) | Item::Info(_) => Vec::new(),
}
}
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
fn undoes(&self) -> bool {
true
}
/// This player's controller settings, bindings included, as the screen found them.
/// This player's controller settings, bindings included, as the screen found them. The
/// radial menu's pages stay as they are: their own screen has its own Undo.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
let radial = std::mem::take(&mut s.controller.radial);
s.controller = before.controller.clone();
s.controller.radial = radial;
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
/// and nor do the radial menu's pages, which this Undo keeps.
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
Some(s.controller != before.controller)
let mut now = s.controller.clone();
now.radial = before.controller.radial.clone();
Some(now != before.controller)
}
}
@@ -793,6 +831,48 @@ mod tests {
assert_eq!(arts(&batch, row), 0);
}
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
/// and another change's Undo keeps them as edited.
#[test]
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
let mut s = Settings::default();
let mut ui = ui(Page::General);
draw(&mut ui, &s, None);
s.controller.radial.remove(1);
draw(&mut ui, &s, None);
assert!(
!ui.changed(),
"Undo here keeps the pages: it has nothing to do"
);
s.controller.invert_y = !s.controller.invert_y;
draw(&mut ui, &s, None);
assert!(ui.changed());
let reached = (0..100).any(|_| {
key(&mut ui, &mut s, K::Tab);
ui.focused() == Some(Target::Undo)
});
assert!(reached);
key(&mut ui, &mut s, K::Enter);
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
}
/// The Bindings page's radial row opens the radial menu's pages.
#[test]
fn the_radial_row_opens_the_radial_pages() {
let mut s = Settings::default();
let mut ui = ui(Page::Bindings);
let at = ui.point_at(&s, SIZE, Item::Radial);
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
assert_eq!(
outputs,
[
CLICK,
Output::Push(crate::options_ui::Page::ControllerRadial)
]
);
}
#[test]
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
let s = Settings::default();
@@ -816,8 +896,8 @@ mod tests {
let mut s = Settings::default();
s.controller.bind(JUMP, Some(Button::North));
let mut ui = ui(Page::Bindings);
// Search, the three pages, then the first action.
for _ in 0..Page::ALL.len() + 2 {
// Search, the three pages, the radial menu's row, then the first action.
for _ in 0..Page::ALL.len() + 3 {
key(&mut ui, &mut s, K::Tab);
}
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
@@ -939,6 +1019,8 @@ mod tests {
(Opt::PadNintendoButtons, 2),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
(Opt::PadRadialMode, 2),
(Opt::PadRadialStick, 3),
] {
let setting = option(opt, "the fixed sentence");
let mut seen = Vec::new();
@@ -972,6 +1054,8 @@ mod tests {
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadLightbar,
Opt::PadRadialMode,
Opt::PadRadialStick,
] {
for _ in 0..4 {
lang::get(choice_description(opt, &settings.controller).unwrap());
@@ -990,6 +1074,7 @@ mod tests {
}
ControllerScreen::default().side();
lang::get(TITLE);
lang::get(RADIAL_PAGES);
for text in [
"Press the button for this. %s cancels.",
"This button also does: %s",
@@ -1,6 +1,7 @@
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
mod slot_sources;
use crate::zip::Zip;
use horizon_world::{
nbt::Tag,
@@ -663,6 +664,8 @@ pub struct DataStore {
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// `slot_source` definitions, evaluated against a command's live accessor.
pub slot_sources: BTreeMap<String, serde_json::Value>,
/// Slot-source tags belong to the same loaded snapshot as their definitions.
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
@@ -759,6 +762,16 @@ impl DataStore {
&& let Ok(v) = serde_json::from_slice(&bytes)
{
s.item_modifiers.insert(id, v);
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
let entries = tag_entries(&v);
let slot = s.slot_source_tags.entry(id).or_default();
if v["replace"].as_bool() == Some(true) {
slot.clear();
}
slot.extend(entries);
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
&& let Some(bytes) = pack.read(&f)
&& let Ok(v) = serde_json::from_slice(&bytes)
@@ -0,0 +1,86 @@
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
use super::DataStore;
use std::collections::BTreeSet;
impl DataStore {
/// TagLoader's required-member validation and first-seen membership order.
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
fn visit(
store: &DataStore,
id: &str,
active: &mut BTreeSet<String>,
) -> Result<Vec<String>, String> {
if !active.insert(id.to_owned()) {
return Err(format!("Recursive slot-source tag #{id}"));
}
let result = (|| {
let entries = store
.slot_source_tags
.get(id)
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
let mut out = Vec::new();
for (entry, required) in entries {
let values = if let Some(tag) = entry.strip_prefix('#') {
visit(store, tag, active)
} else if store.slot_sources.contains_key(entry) {
Ok(vec![entry.clone()])
} else {
Err(format!("Unknown slot source {entry}"))
};
match values {
Ok(values) => {
for value in values {
if !out.contains(&value) {
out.push(value);
}
}
}
Err(error) if *required => return Err(error),
Err(_) => {}
}
}
Ok(out)
})();
active.remove(id);
result
}
visit(self, id, &mut BTreeSet::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
let mut store = DataStore::default();
for name in ["t:a", "t:b"] {
store.slot_sources.insert(
name.into(),
serde_json::json!({"type":"slot_range","slots":"container.0"}),
);
}
store.slot_source_tags.insert(
"t:child".into(),
vec![("t:b".into(), true), ("t:a".into(), true)],
);
store.slot_source_tags.insert(
"t:all".into(),
vec![
("t:a".into(), true),
("#t:child".into(), true),
("t:absent".into(), false),
],
);
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
store
.slot_source_tags
.get_mut("t:all")
.unwrap()
.push(("t:absent".into(), true));
assert!(store.slot_source_tag("t:all").is_err());
store
.slot_source_tags
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
assert!(store.slot_source_tag("t:cycle").is_err());
}
}
@@ -12,6 +12,8 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
mod noise;
pub struct Environment {
system: System,
/// What the packs' expressions read; the frame sets it before sampling.
@@ -559,16 +561,31 @@ impl<'a> BlockHost<'a> {
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
noise::builtin()
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
@@ -596,7 +613,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
num(polytone_noise().get(x, y))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
@@ -0,0 +1,116 @@
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
//! `{1}` or `{0, 1}` matches this construction too.
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
pub(crate) struct PerlinSimplex {
levels: Vec<Option<SimplexNoise>>,
input_factor: f64,
value_factor: f64,
}
impl PerlinSimplex {
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
let mut random = LegacyRandom::new(seed);
let lowest = octaves.iter().copied().min().unwrap_or(0);
let highest = octaves.iter().copied().max().unwrap_or(0);
let count = (highest - lowest + 1) as usize;
let first = SimplexNoise::new(&mut random);
let top = highest;
let wanted = |index: i32| octaves.contains(&(top - index));
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
for index in top + 1..count as i32 {
if index >= 0 && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut random));
} else {
random.consume_count(262);
}
}
if highest > 0 {
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
let mut upper = LegacyRandom::new(value as i64);
for index in (0..top).rev() {
if (index as usize) < count && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
} else {
upper.consume_count(262);
}
}
}
if (0..count as i32).contains(&top) && octaves.contains(&0) {
levels[top as usize] = Some(first);
}
Self {
levels,
input_factor: 2f64.powi(highest),
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
}
}
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
for level in &self.levels {
if let Some(noise) = level {
sum += noise.get2_f64(x * input, y * input) * value;
}
input /= 2.0;
value *= 2.0;
}
sum
}
}
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
pub(crate) fn builtin() -> &'static PerlinSimplex {
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
}
#[cfg(test)]
mod tests {
use super::*;
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
#[test]
fn the_builtin_noise_reads_what_the_mod_draws() {
for (x, z, mod_value) in [
(0, 10, -0.28373),
(3, 10, -0.14683),
(10, 0, 0.31833),
(24, 14, -0.55941),
(40, 25, -0.48358),
(7, 3, -0.68202),
] {
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
assert!(
(value - mod_value).abs() < 0.02,
"({x}, {z}): {value} against {mod_value}"
);
}
}
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
#[test]
fn two_octaves_weigh_a_third_and_two_thirds() {
let both = PerlinSimplex::new(0, &[0, 1]);
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
assert!((both.get(x, y) - mixed).abs() < 1e-12);
}
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
}
}
+6 -23
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -233,7 +231,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -338,7 +335,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -376,7 +372,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:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -418,7 +414,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -613,8 +608,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -691,7 +684,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
@@ -724,7 +716,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
@@ -768,7 +759,6 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
@@ -792,10 +782,9 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
return None;
}
})
@@ -844,9 +833,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -927,7 +913,6 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
@@ -986,7 +971,6 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
@@ -1104,7 +1088,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.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +1127,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+53
View File
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: crate::pack_palette::FluidFogs,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
}
}
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
let fluid = match input.fog_type {
FogType::Lava => input.fluid_fog.lava,
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
input.fluid_fog.water
}
_ => None,
};
if let Some(f) = fluid {
if let Some(rgb) = f.color {
let [r, g, b] = self::rgb(rgb);
fog.color = [r, g, b, 1.0];
}
let end = fog.environmental_end * f.radius;
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
(fog.environmental_start, fog.environmental_end) = (start, end);
(fog.sky_end, fog.cloud_end) = (end, end);
}
let span = (rd / 10.0).clamp(4.0, 64.0);
fog.render_distance_start = rd - span;
fog.render_distance_end = rd;
@@ -495,6 +515,7 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -611,6 +632,38 @@ mod tests {
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
#[test]
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Lava;
input.fire_resistance = true;
let vanilla = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(
(vanilla.environmental_start, vanilla.environmental_end),
(0.0, 5.0)
);
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
color: Some(0x00FF00),
radius: 2.0,
fade: 0.5,
});
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
// The end doubles; half the fade from 0 to 10 starts it at 5.
assert_eq!(
(fog.environmental_start, fog.environmental_end),
(5.0, 10.0)
);
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
+82 -175
View File
@@ -60,6 +60,7 @@ mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod section_fade;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
@@ -74,6 +75,10 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
@@ -201,6 +206,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -236,6 +242,7 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -363,62 +370,6 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
struct FadeInputs<'a> {
now: Instant,
fade_ms: u64,
uploaded: &'a FxHashMap<SectionKey, Instant>,
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
fn next_block_entity_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -1084,6 +1035,9 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1171,6 +1125,8 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
/// `HORIZON_VISBUFFER=1`).
visbuf: Option<visbuffer::Visbuffer>,
@@ -1578,6 +1534,9 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -4945,8 +4904,8 @@ impl Renderer {
}
None => (solid, solid_opaque, solid_cutout, solid_mirror),
};
// The solid quads in the compact layout, while no shader pack reads the arena
// (`follow_packed_layout`).
// The solid quads in the compact layout where every pass reads it; the first world
// frames settle it for the picked pack (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
@@ -5077,6 +5036,7 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5113,6 +5073,7 @@ impl Renderer {
world_warps,
flat_on: flat.is_some(),
packed_quads,
packed_on: packed_wanted(),
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
@@ -5348,6 +5309,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -6747,13 +6710,17 @@ impl Renderer {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
/// for the game to mesh again, as `follow_translucent_layout` does.
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
/// mesh again, as `follow_translucent_layout` does.
fn follow_packed_layout(&mut self) {
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
let want = self.flat.is_some()
&& (!pack || self.pack_terrain_flat)
&& self.native.is_none()
&& self.packed_on;
if want == self.packed_quads {
return;
}
@@ -6764,6 +6731,10 @@ impl Renderer {
let waiting = self.hold.take_all();
self.place_held(waiting);
}
log::info!(
"Horizon: compact solid terrain {}",
if want { "on" } else { "off" }
);
self.packed_quads = want;
packed::pack_quads(want);
let laid_out = self.sections.values().any(|s| s.packed)
@@ -6773,6 +6744,17 @@ impl Renderer {
self.clear_sections(self.layout);
}
}
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
pub fn set_packed_layout(&mut self, on: bool) -> bool {
if on != self.packed_on {
self.packed_on = on;
self.follow_packed_layout();
self.sections_dropped = true;
self.clear_sections(self.layout);
}
self.packed_quads
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
/// as for a new renderer.
@@ -7041,8 +7023,10 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
pub fn output(&self) -> output::EffectiveOutput {
let native = self.native.as_ref().map(|n| n.frame.api);
self.output
.with_pack(self.shader_pack_kind() == Some("iris"), native)
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7213,7 +7197,8 @@ impl Renderer {
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
depth_write: true,
atlas: None,
@@ -7383,7 +7368,8 @@ impl Renderer {
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
@@ -7548,7 +7534,8 @@ impl Renderer {
);
horizon_shaders::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
pull: String::new(),
index: "hz_indices[@]".to_owned(),
}
});
@@ -7975,6 +7962,8 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8319,9 +8308,7 @@ impl Renderer {
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
let hdr_lighting = self.shader_pack_kind().is_none();
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12044,24 +12031,18 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12664,96 +12645,22 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
@@ -15373,8 +15280,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -0,0 +1,42 @@
//! A bind group's validity depends on the identity of its buffers and the
//! bound slice, not on the bytes written into those buffers.
pub(super) struct BindingKey<B, const N: usize> {
pub first: u64,
pub count: u64,
pub buffers: [B; N],
}
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
self.first == first
&& self.count == count
&& self
.buffers
.iter()
.zip(buffers)
.all(|(old, new)| old == new)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changes_to_any_resource_or_range_invalidate_the_binding() {
let key = BindingKey {
first: 16,
count: 8,
buffers: [1u64, 2, 3, 4],
};
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
for i in 0..4 {
let mut resources = [1u64, 2, 3, 4];
resources[i] += 100;
assert!(!key.matches(16, 8, resources.each_ref()));
}
}
}
+99 -45
View File
@@ -19,7 +19,10 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
#[path = "binding_cache.rs"]
mod binding_cache;
use binding_cache::BindingKey;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -85,6 +88,11 @@ pub struct Call {
pub count: u32,
}
struct CachedGroup<const N: usize> {
key: BindingKey<wgpu::Buffer, N>,
group: wgpu::BindGroup,
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
@@ -109,8 +117,9 @@ pub struct Flat {
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// Bindings reused while the range and all GPU buffers are unchanged.
phase2_group: Option<CachedGroup<4>>,
groups: Vec<CachedGroup<3>>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
@@ -515,6 +524,7 @@ impl Flat {
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
groups: Vec::new(),
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -626,6 +636,8 @@ impl Flat {
}
pub fn clear(&mut self) {
self.pages.clear();
self.groups.clear();
self.phase2_group = None;
self.free_ids.clear();
self.next_id = 0;
}
@@ -665,7 +677,6 @@ impl Flat {
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
@@ -729,6 +740,9 @@ impl Flat {
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
// Do not retain bindings for a frame that cannot use flat draws.
self.phase2_group = None;
self.groups.clear();
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
@@ -802,39 +816,63 @@ impl Flat {
phase2,
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
if !self
.phase2_group
.as_ref()
.is_some_and(|cached| cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some(CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some((group, count as u32));
group,
});
}
} else {
self.phase2_group = None;
}
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
let sorted = match source {
Source::Quads => None,
Source::Seen => return None,
Source::Sorted(page) => Some(sorted_page(page)),
};
let mut groups = 0;
for &(source, first, count) in &self.segments {
let sorted = match source {
Source::Quads => None,
Source::Seen => continue,
Source::Sorted(page) => Some(sorted_page(page)),
};
let buffers = [
&self.ranges,
sorted.as_ref().unwrap_or(&self.empty),
&self.indices,
];
if self
.groups
.get(groups)
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten section draws"),
layout: &self.compute_layout,
@@ -845,7 +883,7 @@ impl Flat {
},
wgpu::BindGroupEntry {
binding: 1,
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
resource: buffers[1].as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
@@ -853,17 +891,32 @@ impl Flat {
},
],
});
Some((group, count as u32))
})
.collect();
let cached = CachedGroup {
key: BindingKey {
first,
count,
buffers: buffers.map(|b| b.clone()),
},
group,
};
if groups < self.groups.len() {
self.groups[groups] = cached;
} else {
self.groups.push(cached);
}
}
groups += 1;
}
self.groups.truncate(groups);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
for cached in &self.groups {
let count = cached.key.count as u32;
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
true
@@ -919,16 +972,17 @@ impl Flat {
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
let Some(cached) = &self.phase2_group else {
return;
};
let count = cached.key.count as u32;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
pass.set_bind_group(0, &cached.group, &[]);
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// The camera's translucent calls.
+50 -25
View File
@@ -169,7 +169,8 @@ pub struct Hdr {
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
/// so it can differ from the swapchain's in one frame.
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
/// a screenshot keeps the exact codes.
capture_pipeline: wgpu::RenderPipeline,
capture_params: wgpu::Buffer,
pub output: Output,
@@ -191,7 +192,7 @@ pub struct Hdr {
pub masks: Masks,
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
/// and with HDR Lighting (`look`) off.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -286,8 +287,9 @@ impl Hdr {
entries: &entries,
})
};
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
// for the last pass into the HDR10 swapchain (`pq_dither`).
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[Some(layout)],
@@ -308,7 +310,10 @@ impl Hdr {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("pq_dither", f64::from(u8::from(dither)))],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -321,7 +326,8 @@ impl Hdr {
};
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
let display = layout("Horizon HDR bindings", layout_bindings, true);
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
let pipeline_for =
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
@@ -333,7 +339,7 @@ impl Hdr {
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry, format| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
let pipeline = pass(label, &layout, entry, format, false);
(layout, pipeline)
};
let hud = super::hud_coverage::HUD_FORMAT;
@@ -395,8 +401,8 @@ impl Hdr {
);
Self {
logo: logo_mask(device, queue),
pipeline: pipeline_for(output.format()),
capture_pipeline: pipeline_for(Output::Pq.format()),
pipeline: pipeline_for(output.format(), output == Output::Pq),
capture_pipeline: pipeline_for(Output::Pq.format(), false),
layout: display,
clear_alpha,
hand_mark,
@@ -1001,12 +1007,14 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
/// given: an empty mask.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let (w, h, rgba) = horizon_client_input::window::icon_png()
.and_then(|png| crate::hud::decode_bytes(png).ok())
.unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -1707,19 +1715,32 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
hdr.begin(&queue, true);
hdr.present(&queue, &mut encoder, &target, true, None);
let got = texel(&device, &queue, encoder, &target);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
// code of dither either way.
let exact = (output == Output::Pq)
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
let presented = texel(&device, &queue, encoder, &target);
let exact = exact.map(|copy| {
let encoder = device.create_command_encoder(&Default::default());
texel(&device, &queue, encoder, &copy)
});
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
let Some(got) = got else {
continue;
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
for c in &got[..3] {
let c = f64::from(*c);
let ok = match output {
// 10-bit codes, a step either way for rounding.
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
// 80 nits a unit, in half floats.
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
};
assert!(
ok,
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
);
}
}
}
}
@@ -2076,3 +2097,7 @@ mod lighting_tests {
#[cfg(test)]
#[path = "hdr_written_tests.rs"]
mod written_tests;
#[cfg(test)]
#[path = "hdr_dither_tests.rs"]
mod dither_tests;
@@ -0,0 +1,313 @@
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
//! must stay without it.
use super::{
Hdr, Output,
tests::{fill, gpu},
};
use wgpu::TextureFormat as F;
const SIZE: [u32; 2] = [64, 1024];
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
/// float frame holds.
const FIRST: u32 = 1434;
const ROWS: u32 = 52;
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
fn code(nits: f64) -> f64 {
let y = (nits / 10_000.0).powf(0.1593017578125);
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
}
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
fn ramp_code(y: u32) -> f64 {
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
}
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
format!(
r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}}
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
}}
"#
)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(frame.format().into())],
}),
multiview_mask: None,
cache: None,
});
let view = frame.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
}
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
/// (`Hdr::capture`). Every pixel's bytes, row by row.
fn shown(
(device, queue): &(wgpu::Device, wgpu::Queue),
hdr: &mut Hdr,
screenshot: bool,
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
) -> Vec<u8> {
let frame = hdr.frame(device, SIZE);
let mut encoder = device.create_command_encoder(&Default::default());
paint(&mut encoder, &frame);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let image = if screenshot {
hdr.capture(device, queue, &mut encoder, true).unwrap()
} else {
let image = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
hdr.present(queue, &mut encoder, &image, true, None);
image
};
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
image.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
image.size(),
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
}
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
}
/// An HDR10 picture's 10-bit codes, red, green and blue.
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
let pixels = bytes.as_chunks::<4>().0.iter();
pixels
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
.collect()
}
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
/// length.
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
let (mut longest, mut count) = (0, 0);
for x in 0..SIZE[0] {
let mut run = 0;
for y in 0..SIZE[1] {
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
if y > 0 && at(y) != at(y - 1) {
(longest, count, run) = (longest.max(run), count + 1, 0);
}
run += 1;
}
(longest, count) = (longest.max(run), count + 1);
}
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
}
/// Whether every row of a picture is one value from side to side: no noise in it.
fn rows_are_flat(bytes: &[u8]) -> bool {
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
bytes
.chunks(pixel * SIZE[0] as usize)
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
}
#[test]
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let mut hdr = float_hdr(&gpu, Output::Pq);
// The ramp is what the test says: three codes, top to bottom.
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
assert!((2.9..3.2).contains(&span), "{span}");
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
for channel in 0..3 {
for (i, p) in plain.iter().enumerate() {
let off = (f64::from(p[channel]) - want(i)).abs();
assert!(
off <= 0.75,
"plain {channel} at {i}: {p:?}, not {}",
want(i)
);
}
let (longest, _) = runs(&plain, channel);
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
// With it: each pixel within the noise's one code of its light (and half a code of
// rounding), no column holds a code for long, and a run is a few rows on average.
for (i, d) in dithered.iter().enumerate() {
let off = (f64::from(d[channel]) - want(i)).abs();
assert!(
off <= 1.75,
"dithered {channel} at {i}: {d:?}, not {}",
want(i)
);
}
let (longest, mean) = runs(&dithered, channel);
eprintln!(
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
);
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
// The mean of each 64-row block is the light meant.
let mut worst = 0.0_f64;
for block in 0..SIZE[1] / 64 {
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
let n = rows.len() as f64;
let got = dithered[rows.clone()]
.iter()
.map(|d| f64::from(d[channel]))
.sum::<f64>()
/ n;
let meant = rows.map(want).sum::<f64>() / n;
worst = worst.max((got - meant).abs());
assert!(
(got - meant).abs() < 0.25,
"block {block} of {channel}: {got}, not {meant}"
);
}
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
}
}
#[test]
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
let Some(gpu) = gpu() else {
return;
};
let mut hdr = float_hdr(&gpu, Output::Pq);
let grey = |g: f64| {
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
fill(encoder, frame, [g, g, g, 1.0]);
}
};
// How far the picture's mean code is from `end`.
let mean_from = |picture: &[[u32; 3]], end: f64| {
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
(sum / (picture.len() * 3) as f64 - end).abs()
};
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
// the end, the signal plus that noise passes half a code only where the noise is over half
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
// shows between 0.02 and 0.32 of a code, always short of the third.
let leans = |mean: f64| (0.015..0.325).contains(&mean);
// Black gets no noise: every pixel stays off.
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
assert!(black.iter().all(|p| *p == [0; 3]));
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
let dim = 0.00038433074951171875;
let meant = code(200.0 * f64::powf(dim, 2.2));
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
assert!(near_black.iter().flatten().all(|c| *c <= 1));
let mean = mean_from(&near_black, 0.0);
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
assert!(leans(mean), "{mean} above black");
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
// the peak is held at it, so the Peak setting is the signal here.
hdr.peak = 10_000.0;
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(top.iter().all(|p| *p == [1023; 3]));
// A third of a code under the top, mirrored: 1022 or 1023.
let (mut under, mut over) = (9000.0, 10_000.0);
for _ in 0..60 {
let middle = (under + over) / 2.0;
if code(middle) < 1023.0 - 1.0 / 3.0 {
under = middle;
} else {
over = middle;
}
}
hdr.peak = under as f32;
let meant = code(f64::from(hdr.peak));
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
let mean = mean_from(&near_top, 1023.0);
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
assert!(leans(mean), "{mean} under the top");
}
#[test]
fn scrgb_and_screenshots_get_no_dither() {
let Some(gpu) = gpu() else {
return;
};
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
ramp(&gpu.0, encoder, frame);
};
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
// scRGB's swapchain is float: every row is one value from side to side.
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
// dithered one included, and has no noise in it.
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
assert!(rows_are_flat(&screenshot));
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
// The swapchain's own picture differs from it: the dither is on there.
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
}
@@ -8,7 +8,7 @@
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
@@ -1596,7 +1596,11 @@ pub fn frame_inputs(
paper_white: 80.0,
peak: 80.0,
black_level: 0.0,
padding: [0; 3],
// No pack is served another space yet (`[frame] primaries`).
primaries: Primaries::Bt709 as u32,
luminance_gap: [0; 2],
luminance: Primaries::Bt709.luminance(),
padding: 0,
}
}
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
pub(crate) ui_brightness: f32,
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
/// Lighting has nothing to switch (`with_pack`).
pub(crate) pack_lights: bool,
}
impl Default for EffectiveOutput {
fn default() -> Self {
@@ -166,7 +169,22 @@ impl EffectiveOutput {
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
pack_lights: false,
};
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
/// `Renderer::render`); under the rest Horizon lifts nothing.
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
let pack_lights = shaders
|| native.is_some_and(|api| {
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
});
Self {
pack_lights,
..self
}
}
/// Whether the destination offers HDR output.
pub fn hdr_offered(&self) -> bool {
self.offered.is_some()
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -548,27 +548,107 @@ impl PackAtlas {
}
}
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
/// inputs, for vertex `hz_vertex_index`.
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
/// give back their quad's vertices bit for bit, so packs read either layout alike.
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_w = hz_av * 6u;\n \
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
hz_uv = hz_vertices[hz_w + 3u];\n\
}\n\
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
}\n\
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
uint hz_axis = (hz_o >> 12u) & 3u;\n \
uint hz_side = (hz_o >> 14u) & 1u;\n \
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
uint hz_sel = hz_lo.z >> 24u;\n \
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
hz_p = floatBitsToUint(vec3(hz_pos));\n \
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
}\n\
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
hz_l &= 0xfffu;\n \
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
uint hz_t = hz_lo.x;\n \
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
}\n";
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
uint hz_own = hz_qv & 3u;\n \
uint hz_av = hz_qv & ~3u;\n \
uint hz_slot = hz_qv;\n \
bool hz_rec = false;\n \
uvec3 hz_lo = uvec3(0u);\n \
uvec3 hz_hi = uvec3(0u);\n \
if (hz_av >= 0x10000000u) {\n \
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
uint hz_rw = hz_slot * 6u;\n \
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
hz_av = hz_slot + hz_lo.x;\n \
}\n \
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
uvec2 hz_rest;\n \
if (hz_rec) {\n \
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
} else {\n \
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
uint hz_ow = (hz_av + hz_own) * 6u;\n \
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
}\n \
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
hz_v_pos = uintBitsToFloat(hz_vp);\n \
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
hz_v_layer = hz_vr.y & 65535u;\n \
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
uint hz_w3 = hz_uv3;\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
@@ -620,11 +700,19 @@ impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
// solid quads in the compact layout; others read them as vertex attributes.
let mut declarations = if flat {
String::from(
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
)
} else {
String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
)
};
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
@@ -637,7 +725,8 @@ impl PackAtlas {
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
declarations.push_str(CORNER_FN);
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
@@ -700,6 +789,7 @@ impl PackAtlas {
);
let mut body = body;
if flat {
body.insert_str(0, PULL_FLAT);
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: super::PULL.to_owned(),
pull: String::new(),
index: "@".to_owned(),
}),
..Default::default()
@@ -245,6 +245,135 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
#[test]
fn compact_records_feed_a_pack_the_vertices_they_hold() {
use crate::mesher::packed;
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
queue.submit([encoder.finish()]);
atlas.separate_ao = true;
let tint = 0x7F_B238u32;
let ao = [255u32, 153, 102, 204];
let face = |x: f32, y: f32| {
let pos = [
[x, y, 5.0],
[x, y, 6.0],
[x + 1.0, y, 6.0],
[x + 1.0, y, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
(0..4).map(move |i| {
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 0x8000,
light: 0xF0 | 0x40 << 8,
color: r | g << 8 | b << 16 | ao[i] << 24,
}
})
};
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
.into_iter()
.flat_map(|(x, y)| face(x, y))
.collect();
let records = packed::layout(&solid, &[tint; 4]);
assert_eq!(
packed::packed_quads(&records, 4),
3,
"whole faces pack, the slab doesn't"
);
let page = bytemuck::cast_slice(&records);
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for expression in [
"gl_Vertex",
"gl_Color",
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
"vec4(gl_Normal, 0.0)",
"at_tangent",
"at_midBlock",
"vec4(mc_midTexCoord, mc_Entity.xy)",
] {
for shadow in [false, true] {
let flags = [true, shadow];
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
let compact = sample_page(
&device,
&queue,
&atlas,
page,
packed_range.clone(),
origin,
flags,
expression,
);
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
if bits(&wide) != bits(&compact) {
failures.push(format!(
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
));
}
if expression == "gl_Color" {
for (corner, want) in ao.iter().enumerate() {
let got = compact[corner][3];
if (got - *want as f32 / 255.0).abs() > 1e-6 {
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
}
}
}
}
}
// A pack's frames keep the translucent quads in the section arena after the solid range: three
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
let water: Vec<_> = face(9.0, 15.0).collect();
let at = page.len() as u32;
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
let flags = [true, false];
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
let page = bytemuck::cast_slice(&page);
let after = sample_page(
&device,
&queue,
&atlas,
page,
at..at + 4,
origin,
flags,
expression,
);
if wide != after {
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -271,18 +400,38 @@ fn sample_probe(
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let page = bytemuck::cast_slice(vertices);
let drawn = 0..vertices.len() as u32;
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
}
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
#[allow(clippy::too_many_arguments)]
fn sample_page(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
page: &[u8],
drawn: std::ops::Range<u32>,
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let count = drawn.len() as u32;
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
drawn.start,
2. / count as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
@@ -369,7 +518,8 @@ fn sample_probe(
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
buffers: if flat { &[] } else { &vertex_layouts[..] },
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
@@ -408,7 +558,7 @@ fn sample_probe(
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
page,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
@@ -444,7 +594,7 @@ fn sample_probe(
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: vertices.len() as u32,
width: count,
height: 1,
depth_or_array_layers: 1,
},
@@ -457,7 +607,7 @@ fn sample_probe(
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 16,
size: u64::from(count) * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -481,11 +631,11 @@ fn sample_probe(
for (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(0, data.slice(..));
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(0..vertices.len() as u32, 0..1);
pass.draw(drawn, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
@@ -1137,6 +1137,10 @@ fn components(name: &str) -> usize {
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
// are ints.
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -1208,6 +1212,28 @@ mod tests {
(&matrix, "gbufferModelView", "mat4", true),
(&matrix, "gbufferModelView", "mat3", false),
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
// ints.
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"vec4",
true,
),
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"float",
false,
),
(
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
"horizonWaterPatch0",
"vec4",
true,
),
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
] {
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
}
@@ -0,0 +1,321 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
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(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,60 @@
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
use crate::fast_hash::FxHashMap;
use std::time::Instant;
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
pub(super) fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
pub(super) struct FadeInputs<'a> {
pub(super) now: Instant,
pub(super) fade_ms: u64,
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
+6 -5
View File
@@ -30,8 +30,9 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
/// uses it, and the HDR page offers calibration.
pub peak_guessed: bool,
}
@@ -40,7 +41,7 @@ pub struct Reported {
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
/// The system says, but not where its peak comes from: the page offers calibration.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
@@ -1355,7 +1356,7 @@ mod tests {
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
@@ -1370,7 +1371,7 @@ mod tests {
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
// Auto still takes the reported peak, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
@@ -2,16 +2,21 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
//! Its colours come from `menu_style`, given at startup (in the official game the design
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
//! the one main button, in vanilla's font.
use crate::menu_style::{self, SettingsColours};
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,54 +41,91 @@ impl Cost {
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
Cost::None => b().ink_soft,
Cost::Low | Cost::Saves => b().success,
Cost::Medium => b().warn,
Cost::High => b().danger,
}
}
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
use menu_style::hud_colour as hex;
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
fn glass() -> u32 {
hex(b().page, 118)
}
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
pub fn edge() -> u32 {
hex(b().ink, 26)
}
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
fn group_fill() -> u32 {
hex(b().ink, 11)
}
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
pub fn hover() -> u32 {
hex(b().ink, 18)
}
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
pub fn open() -> u32 {
hex(b().ink, 26)
}
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
fn well_fill() -> u32 {
hex(b().outline, 102)
}
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
fn button_fill() -> u32 {
hex(b().ink, 20)
}
fn button_hover() -> u32 {
hex(b().ink, 41)
}
fn track_fill() -> u32 {
hex(b().ink, 64)
}
fn handle_fill() -> u32 {
hex(b().ink, 255)
}
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
pub fn muted() -> u32 {
hex(b().muted, 255)
}
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
pub fn veil() -> u32 {
hex(b().page, 77)
}
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
fill(batch, atlas, rect, glass());
ring(batch, atlas, rect, edge());
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
fill(batch, atlas, rect, group_fill());
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
fill(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
(true, true) => accent(),
(true, false) => muted(),
(false, _) => box_edge(),
};
ring(batch, atlas, rect, edge);
if on {
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
@@ -198,17 +240,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
let ty = y + ((h - 8.0) / 2.0).floor();
let left = x + 2.0 + room;
scrolling_label(
@@ -252,7 +294,7 @@ pub fn slider(
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
scrolling_label(
batch,
atlas,
@@ -263,17 +305,22 @@ pub fn slider(
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(batch, atlas, [x, line, w, 2.0], track_fill());
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
if live { accent() } else { muted() },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
fill(
batch,
atlas,
handle,
if live { handle_fill() } else { muted() },
);
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
@@ -290,13 +337,13 @@ pub fn cycler(
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
let arrows = if hover && live { WHITE } else { muted() };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(false, _) => muted(),
(true, true) => WHITE,
(true, false) => SOFT,
(true, false) => soft(),
};
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
+75 -13
View File
@@ -697,7 +697,7 @@ fn description(
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
@@ -705,7 +705,7 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
// A peak of unknown source isn't said to be set up; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
@@ -1110,7 +1110,8 @@ impl Item {
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1141,6 +1142,9 @@ impl Item {
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::HdrLighting) if out.pack_lights => {
Some("The shader pack lights the world itself.")
}
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1215,10 +1219,13 @@ impl Item {
}
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
match self {
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
lang::tr("%s chunks", &[&s.render_distance]),
),
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
Text::option_name_value(
Text::literal(lang::get("Full Detail Distance")),
Text::tr("options.chunks", &[&s.render_distance]),
)
}
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s, out),
Item::Extra(e) => Text::option_name_value(
@@ -1314,7 +1321,11 @@ impl Item {
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
fn shown(item: Item, s: &Settings) -> bool {
item != Item::Horizon(H::Upscaling) || s.horizon.taa
match item {
Item::Horizon(H::Upscaling) => s.horizon.taa,
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
_ => true,
}
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
@@ -1426,7 +1437,7 @@ impl Screen for GraphicsScreen {
g::gpu_culling_offer(),
g::reduced_far_detail_suggested(),
);
shell::stamp_of(&(read(s), offers))
shell::stamp_of(&(read(s), s.fullbright_held, offers))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
@@ -1986,6 +1997,31 @@ mod tests {
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
}
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
/// kept for the next world that allows it (#3823).
#[test]
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
let mut s = Settings::default();
s.extras.fullbright = true;
let screen = GraphicsScreen::default();
let listed = |s: &Settings, item| {
let sheets = screen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).any(|r| r.id == item)
};
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
let lights = Item::Extra(crate::extras::Item::DynamicLights);
assert!(listed(&s, fullbright));
let free = screen.stamp(&s);
s.fullbright_held = true;
assert!(!listed(&s, fullbright));
assert!(listed(&s, lights));
assert_ne!(screen.stamp(&s), free);
assert!(s.extras.fullbright);
s.fullbright_held = false;
assert!(listed(&s, fullbright));
}
/// The strip says what a row does, what its chosen value does where that differs, and why a
/// greyed row can't be used; the search finds a row by what it does.
#[test]
@@ -2106,10 +2142,36 @@ mod tests {
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
format!("Full Detail Distance: {near} chunks")
);
}
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
/// (#14880).
#[test]
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
struct Vanilla;
impl lang::ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"options.renderDistance": "Render Distance",
"options.chunks": "%s Chunks",
"options.generic_value": "%s: %s"
}))
.unwrap()
})
}
}
let mut s = Settings::default();
let near = s.render_distance;
let render = Item::Vanilla(Opt::RenderDistance);
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
let off = render.label(&s, &NONE).plain();
s.horizon.lod_distance = 256;
(off, render.label(&s, &NONE).plain())
});
assert_eq!(off, format!("Render Distance: {near} Chunks"));
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
}
#[test]
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
use horizon_shader_api::Api;
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrLighting);
let lights = Some("The shader pack lights the world itself.");
let out = offering(true, None);
// No pack running, as with one picked but refused, missing or its mod off: live.
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
None
);
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
lights
);
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
let float = EffectiveOutput {
frame: crate::gfx::FrameConfiguration {
format: wgpu::TextureFormat::Rgba16Float,
..out.frame
},
..out
};
assert_eq!(
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
lights
);
// HDR's own reasons come first.
assert_eq!(
item.blocked(&s, &NONE.with_pack(true, None)),
Some("Your display or system doesn't offer HDR output.")
);
s.horizon.hdr = HdrMode::Off;
assert_eq!(
item.blocked(&s, &out.with_pack(true, None)),
Some("Turn HDR on to use it.")
);
}
#[test]
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let mut s = Settings::default();
@@ -121,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
@@ -157,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
@@ -176,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
@@ -305,3 +348,30 @@ fn undo_never_restores_a_held_hdr_start() {
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
/// 1080p window at scale 4 is wider): a longer one would end in "…".
#[test]
fn the_calibrate_descriptions_fit_the_strip() {
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut guessed = offering(true, Some(true));
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let mut changed = Settings::default();
changed.horizon.hdr_peak = 800;
let item = Item::Horizon(H::HdrCalibrate);
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
for (s, out) in [
(Settings::default(), NONE),
(Settings::default(), guessed),
(Settings::default(), offering(true, Some(true))),
(changed, guessed),
] {
let text = description(item, &s, &out, "-");
assert_ne!(text, "-");
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
assert!(lines.len() <= 2, "{text}");
}
}
+45 -6
View File
@@ -173,13 +173,52 @@ fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
return vec4(nits / 80.0, 1.0);
}
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
// lifted.
fn display(light: vec3<f32>) -> vec4<f32> {
// On for the HDR10 swapchain alone (Hdr::new): its 10 bits get dither. A screenshot's HDR10 copy
// (Hdr::capture) keeps the exact codes, and scRGB's floats need none.
override pq_dither: bool = false;
// Three even random values in [0, 1) for `at`: PCG's 3D hash (Jarzynski and Olano, "Hash Functions
// for GPU Rendering", JCGT 2020). Integers only, so it is the same on every GPU and at every
// resolution, where hashes built on `fract(sin())` repeat or streak at 4K's coordinates.
// Copyright 2020 Mark Jarzynski & Marc Olano, MIT (github.com/markjarzynski/PCG3D; the license is in
// THIRD_PARTY_NOTICES.md).
fn hash3(at: vec3<u32>) -> vec3<f32> {
var v = at * 1664525u + 1013904223u;
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
v ^= v >> vec3(16u);
v.x += v.y * v.z;
v.y += v.z * v.x;
v.z += v.x * v.y;
return vec3<f32>(v >> vec3(8u)) / 16777216.0;
}
// The PQ signal `e` with dither for its 10 bits: triangular noise of one code either way, each
// channel's own, so a slow gradient (a clear sky is a few codes tall) shows as grain a code deep
// instead of bands, and its mean is the light meant. The noise is the pixel's alone: no frame
// counter, so a still picture stays still. Within a code of black or of the top it shrinks to
// what fits: the signal never leaves 0..1, and black stays exactly black (an OLED's pixel off).
// The price is in that last code alone: the smaller noise keeps the signal's mean but not the
// rounded one, which leans to the end (a third of a code from it shows as an eighth). Under
// 0.00004 nits at black, that is the accepted cost of a true 0.
fn dithered(e: vec3<f32>, pixel: vec2<f32>) -> vec3<f32> {
let at = vec2<u32>(pixel);
let noise = hash3(vec3(at, 0u)) + hash3(vec3(at, 1u)) - 1.0;
return e + noise * min(min(e, 1.0 - e), vec3(1.0 / 1023.0));
}
// Light in units of paper white at the pixel `pos`, as the swapchain takes it: HDR10 or scRGB,
// the black level lifted.
fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
let lifted = select(e, lift_black(e), params.black > 0.0);
if pq_dither {
return vec4(dithered(lifted, pos.xy), 1.0);
}
return vec4(lifted, 1.0);
}
if params.black > 0.0 {
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
@@ -209,7 +248,7 @@ fn display(light: vec3<f32>) -> vec4<f32> {
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
return display(light);
return display(light, pos);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
@@ -328,5 +367,5 @@ override screenshot_shoulder: bool = true;
if calibrating(pos) {
return calibration_picture(pos);
}
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)), pos);
}
+14 -2
View File
@@ -52,7 +52,7 @@ pub struct TextureFlags {
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
let meta = crate::packs::first_json(json.as_bytes())?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
@@ -387,7 +387,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
@@ -3110,6 +3110,18 @@ mod coverage_tests;
mod tests {
use super::*;
#[test]
fn animated_texture_metadata_ignores_trailing_pack_signature() {
let meta = r#"{"animation":{"frametime":2,"width":2,"height":2}}
// High Contrast pack signature"#;
let animation = animation_of(Some(meta)).expect("the first JSON value is metadata");
assert_eq!(animation.frame_size(8, 4), [2, 2]);
let pixels = (0..8 * 4).flat_map(|i| [i as u8, 0, 0, 255]).collect();
let (_, height, first) = first_frame((8, 4, pixels), Some(&animation));
assert_eq!(height, 2);
assert_eq!(first.len(), 2 * 2 * 4);
}
#[test]
fn object_skin_revision_tracks_changes_and_capacity_clear() {
let mut atlas = HudAtlas::boot();
@@ -3,8 +3,9 @@
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
//! no motion ever decides what input does.
//!
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
//! of them, at the theme's duration or a shorter one of its own.
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
//! own.
//!
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
@@ -16,7 +17,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! // Half way through: partly faded in, partly grown from 94%.
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
//! let alpha = motion::fade(open, clock, opened);
@@ -26,7 +27,6 @@
//! let still = Clock::new(opened, true);
//! assert_eq!(motion::fade(open, still, opened), 1.0);
//! ```
use horizon_theme::Motion;
use std::time::{Duration, Instant};
/// The time a frame shows, built once a frame by the game.
@@ -50,28 +50,20 @@ impl Clock {
}
}
/// A duration and one of the design system's easing curves.
/// A duration and an easing curve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
/// The CSS `cubic-bezier` control points of its curve.
pub curve: [f32; 4],
}
impl Tween {
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
/// the same curves.
pub const fn new(duration: Duration, motion: Motion) -> Self {
Self {
duration,
curve: motion.curve,
}
}
/// A theme motion as it is: its own duration and curve.
pub const fn of(motion: Motion) -> Self {
Self::new(motion.duration(), motion)
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
/// curves.
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
Self { duration, curve }
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -84,7 +76,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
horizon_theme::bezier(self.curve, self.linear(clock, since))
bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -98,6 +90,29 @@ impl Tween {
}
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
/// returns y.
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
let at = |a: f32, b: f32, s: f32| {
let u = 1.0 - s;
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
let (mut lo, mut hi) = (0f32, 1f32);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if at(x1, x2, mid) < t {
lo = mid;
} else {
hi = mid;
}
}
at(y1, y2, (lo + hi) / 2.0)
}
/// An alpha from 0 to 1 as something fades in.
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
tween.progress(clock, since)
@@ -145,7 +160,7 @@ mod tests {
#[test]
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
@@ -160,8 +175,7 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
assert_eq!(fade(tween, still, t0), 1.0);
assert_eq!(fade_out(tween, still, t0), 0.0);
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
@@ -173,7 +187,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -184,10 +198,25 @@ mod tests {
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
}
#[test]
fn the_curve_is_the_design_systems() {
for motion in horizon_theme::MOTION {
for i in 0..=20 {
let t = i as f32 / 20.0;
assert_eq!(
bezier(motion.curve, t),
motion.ease(t),
"{} at {t}",
motion.name
);
}
}
}
#[test]
fn a_turn_takes_the_short_way_round() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
let from = 7.0 * FRAC_PI_4;
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
@@ -1,6 +1,7 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
//! the hotbar at the bottom centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
@@ -34,6 +35,16 @@ pub const GAP: f32 = 2.0;
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
/// bottom.
StatusRows,
}
impl Keep {
/// It sits at the top of the view, not the bottom.
fn at_top(self) -> bool {
matches!(self, Self::BossBars)
}
}
/// A view and the keep-outs its HUD shows this frame.
@@ -90,10 +101,20 @@ impl Room {
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.filter(|(keep, _)| keep.at_top())
.map(|(_, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
/// rows' top, or the view's height with none.
pub fn bottom(&self) -> f32 {
self.keeps()
.filter(|(keep, _)| !keep.at_top())
.map(|(_, [_, y, _, _])| y)
.fold(self.height, f32::min)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
@@ -134,4 +155,15 @@ mod tests {
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
#[test]
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
let room = Room::new(427.0, 240.0);
assert_eq!(room.bottom(), 240.0);
// Hearts and armour from y 191 down.
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
assert_eq!(rows.bottom(), 191.0);
assert_eq!(rows.top(), 0.0);
assert_eq!(rows.below_top(18.0), 18.0);
}
}
@@ -38,6 +38,12 @@
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::{colour, fit};
use crate::menu_style::{self, OverlayColours};
/// The overlays' colours (`menu_style`, given at startup).
fn overlay() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
/// The selected tab's fill and text, and the others'.
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub fn selected_text() -> u32 {
colour(overlay().page, 255)
}
pub fn tab_fill() -> u32 {
colour(overlay().page, 191)
}
pub fn tab_text() -> u32 {
colour(overlay().backdrop_muted, 255)
}
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
if on {
arrow_colour()
} else {
arrow_off_colour()
},
true,
);
}
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
for tab in &row.tabs {
let [tx, ty, tw, th] = tab.area;
let (fill, text) = if tab.selected {
(SELECTED_FILL, SELECTED_TEXT)
(SELECTED_FILL, selected_text())
} else {
(TAB_FILL, TAB_TEXT)
(tab_fill(), tab_text())
};
let tab_start = batch.vertices.len();
// Cut where the edges' fades start, so each fade runs over its own piece.
@@ -380,15 +400,16 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
}
#[test]
@@ -131,6 +131,23 @@ pub enum GuiDraw {
pub const WHITE: u32 = 0xFFFF_FFFF;
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
/// of the HUD begins.
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
draws
.iter()
.filter_map(|d| match *d {
GuiDraw::Sprite { y, .. }
| GuiDraw::AtlasSprite { y, .. }
| GuiDraw::Blit { y, .. }
| GuiDraw::Text { y, .. }
| GuiDraw::Item { y, .. } => Some(y),
GuiDraw::Fill { y0, .. } => Some(y0),
GuiDraw::Push(_) | GuiDraw::Pop => None,
})
.min()
}
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
GuiDraw::Sprite {
sprite: Cow::Borrowed(sprite),
@@ -583,6 +583,9 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
/// span the same.
pub const HOTBAR_HALF_WIDTH: i32 = 91;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
+4
View File
@@ -74,6 +74,7 @@ pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
@@ -109,6 +110,8 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod radial_editor;
pub mod radial_overlay;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
@@ -169,6 +172,7 @@ pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::radial;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
+135 -21
View File
@@ -22,6 +22,7 @@ use crate::{
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
cell::RefCell,
collections::{HashMap, VecDeque},
path::Path,
sync::Arc,
@@ -365,6 +366,48 @@ fn give_scratch(scratch: LightScratch) {
}
}
thread_local! {
static RELIGHT_SCRATCH: RefCell<RelightScratch> = RefCell::new(RelightScratch::new());
}
struct RelightScratch {
decrease: VecDeque<(Pos, u8)>,
cleared: Vec<Pos>,
increase: VecDeque<Pos>,
sources: FxHashMap<(i32, i32), i32>,
}
impl RelightScratch {
fn new() -> Self {
Self {
decrease: VecDeque::new(),
cleared: Vec::new(),
increase: VecDeque::new(),
sources: FxHashMap::default(),
}
}
fn clear(&mut self) {
const MAX_CAPACITY: usize = 16_384;
self.decrease.clear();
if self.decrease.capacity() > MAX_CAPACITY {
self.decrease.shrink_to(MAX_CAPACITY);
}
self.cleared.clear();
if self.cleared.capacity() > MAX_CAPACITY {
self.cleared.shrink_to(MAX_CAPACITY);
}
self.increase.clear();
if self.increase.capacity() > MAX_CAPACITY {
self.increase.shrink_to(MAX_CAPACITY);
}
self.sources.clear();
if self.sources.capacity() > MAX_CAPACITY {
self.sources.shrink_to(MAX_CAPACITY);
}
}
}
/// Where a section sits in the 48-wide region.
#[derive(Clone, Copy)]
struct At {
@@ -880,12 +923,12 @@ impl<'w> Region<'w> {
Self {
layout: world.layout(),
world,
slots: Vec::new(),
slots: Vec::with_capacity(16),
index: FxHashMap::default(),
last: ((i32::MIN, 0, 0), NONE),
}
}
#[inline]
#[inline(always)]
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
let s = self.layout.section_index(y)?;
let key = (x >> 4, s as i32, z >> 4);
@@ -1142,10 +1185,25 @@ pub fn relight(
force: bool,
changed: &mut LightChanges,
) {
let mut region = Region::new(world);
changed.keyed(region.layout);
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
changed.keyed(world.layout());
if positions.is_empty() {
return;
}
RELIGHT_SCRATCH.with_borrow_mut(|scratch| {
scratch.clear();
let mut region = Region::new(world);
relight_region(
&mut region,
tables,
sky_light,
positions,
force,
scratch,
changed,
);
region.finish();
scratch.clear();
});
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
@@ -1170,10 +1228,11 @@ fn relight_region(
sky_light: bool,
positions: &[Pos],
force: bool,
scratch: &mut RelightScratch,
changed: &mut LightChanges,
) {
crate::spans::time("lt_block", || {
relight_layer(world, tables, false, positions.to_vec(), force, changed)
relight_layer(world, tables, false, positions, force, scratch, changed)
});
if sky_light {
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
@@ -1201,7 +1260,7 @@ fn relight_region(
}
});
crate::spans::time("lt_sky", || {
relight_layer(world, tables, true, seeds, force, changed)
relight_layer(world, tables, true, &seeds, force, scratch, changed)
});
}
}
@@ -1209,14 +1268,30 @@ fn relight_layer(
world: &mut Region,
tables: &LightTables,
sky: bool,
seeds: Vec<Pos>,
seeds: &[Pos],
force: bool,
scratch: &mut RelightScratch,
changed: &mut LightChanges,
) {
let mut decrease: VecDeque<(Pos, u8)> = VecDeque::new();
let mut cleared: Vec<Pos> = Vec::new();
let mut increase: VecDeque<Pos> = VecDeque::new();
for p in seeds {
scratch.decrease.clear();
scratch.cleared.clear();
scratch.increase.clear();
scratch.sources.clear();
if seeds.len() > scratch.decrease.capacity() {
scratch.decrease.reserve(seeds.len());
}
if seeds.len() > scratch.cleared.capacity() {
scratch.cleared.reserve(seeds.len());
}
if seeds.len() > scratch.increase.capacity() {
scratch.increase.reserve(seeds.len());
}
let decrease = &mut scratch.decrease;
let cleared = &mut scratch.cleared;
let increase = &mut scratch.increase;
for &p in seeds {
let Some(old) = level(world, sky, p) else {
continue;
};
@@ -1267,13 +1342,22 @@ fn relight_layer(
}
}
// Sources inside the cleared area light up again.
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
let sources = &mut scratch.sources;
let mut last_xz = (i32::MIN, i32::MIN);
let mut last_lowest = 0;
crate::spans::time("lt_sources", || {
for p in cleared {
for &p in cleared.iter() {
let seed = if sky {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
let lowest = if (p.0, p.2) == last_xz {
last_lowest
} else {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
last_xz = (p.0, p.2);
last_lowest = lowest;
lowest
};
if p.1 >= lowest { 15 } else { 0 }
} else {
world
@@ -2119,9 +2203,39 @@ pub(crate) mod tests {
relight(&mut w, &t, true, &positions, false, &mut changed);
}
println!(
"relight_bench: {:?}, {} sections",
start.elapsed(),
changed.sections().count()
"relight_bench (large batch: 400 rounds x 32 blocks): {:?}",
start.elapsed()
);
}
#[test]
#[ignore]
fn relight_single_block_bench() {
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
let t = tables();
let mut rng = 0x2545_f491_u32;
let mut next = |n: i32| {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
(rng % n as u32) as i32
};
let start = std::time::Instant::now();
let mut changed = LightChanges::default();
for round in 0..2000 {
let state = [TORCH, SLAB, AIR][round % 3];
let pos = (next(32) - 8, next(12), next(32) - 8);
Arc::make_mut(w.get_mut(&(pos.0 >> 4, pos.2 >> 4)).unwrap()).set(
pos.0 & 15,
pos.1,
pos.2 & 15,
state,
);
relight(&mut w, &t, true, &[pos], false, &mut changed);
}
println!(
"relight_single_block_bench (single block: 2000 rounds): {:?}",
start.elapsed()
);
}
@@ -0,0 +1,150 @@
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
//! read it.
//!
//! The layer above gives it once at startup ([`install`]), before the first frame; the
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
//! use their own fonts. A read is one atomic load, and only those screens make it.
use std::sync::OnceLock;
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuStyle {
pub settings: SettingsColours,
pub overlay: OverlayColours,
pub sizes: Sizes,
/// The overlays' TrueType faces; none: they draw no text.
pub fonts: Option<Fonts>,
}
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SettingsColours {
/// The plate behind everything, drawn see-through.
pub page: u32,
/// Text, and the light the panels, rules, hovers and controls are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// Labels, descriptions, what can't be used.
pub muted: u32,
/// Wells and the dark edge of small marks.
pub outline: u32,
/// Focus, a checked box, a slider's fill.
pub accent: u32,
/// The one main button, and while hovered.
pub face: u32,
pub face_hover: u32,
pub success: u32,
pub warn: u32,
pub danger: u32,
}
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OverlayColours {
/// The dark page under text and tabs.
pub page: u32,
/// Muted text over a veiled backdrop.
pub backdrop_muted: u32,
/// Text and the light hover and pressed plates are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// One-pixel rules between rows.
pub rule: u32,
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
/// A lit choice and its bright edge (the controller dial's sector).
pub lit: u32,
pub lit_edge: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sizes {
pub caption: f32,
pub label: f32,
pub ui: f32,
pub body: f32,
pub title: f32,
/// The boot screen's wordmark at its smallest, and its console lines.
pub display: f32,
pub micro: f32,
/// Fields; plates (hover rows, selection); a large control's height.
pub radius: f32,
pub radius_plate: f32,
pub control_large: f32,
}
/// TrueType faces (`ui_theme::Face`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fonts {
pub regular: &'static [u8],
pub medium: &'static [u8],
pub semibold: &'static [u8],
pub mono: &'static [u8],
}
/// The look with nothing given: vanilla's greys and chat colours.
pub const NEUTRAL: MenuStyle = MenuStyle {
settings: SettingsColours {
page: 0x101010,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
muted: 0xa0a0a0,
outline: 0x000000,
accent: 0xffffff,
face: 0x555555,
face_hover: 0x6f6f6f,
success: 0x55ff55,
warn: 0xffff55,
danger: 0xff5555,
},
overlay: OverlayColours {
page: 0x101010,
backdrop_muted: 0xa0a0a0,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
lit: 0xffffff,
lit_edge: 0xffffff,
},
sizes: Sizes {
caption: 12.0,
label: 13.0,
ui: 14.0,
body: 15.0,
title: 28.0,
display: 44.0,
micro: 11.0,
radius: 0.0,
radius_plate: 0.0,
control_large: 40.0,
},
fonts: None,
};
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
/// Give the menus their look: call it once at startup. A second call keeps the first look and
/// returns false.
pub fn install(style: MenuStyle) -> bool {
STYLE.set(style).is_ok()
}
/// The look given to [`install`], or [`NEUTRAL`].
#[inline]
pub fn get() -> &'static MenuStyle {
STYLE.get().unwrap_or(&NEUTRAL)
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
}
@@ -13,8 +13,9 @@
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
@@ -27,6 +28,8 @@ pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
pub const ALPHA_STEP: u32 = 51;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
@@ -133,12 +136,24 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
let mut codes = [0u32; 3];
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
let a = v.color >> 24;
if v.color & 0xFF00_0000 != alpha {
if a == 0 || a % ALPHA_STEP != 0 {
return None;
}
*code = a / ALPHA_STEP;
}
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
@@ -190,12 +205,18 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
let alpha = if code == 0 {
w0 & 0xFF00_0000
} else {
(code * ALPHA_STEP) << 24
};
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
color: r | gr << 8 | b << 16 | alpha,
}
}
@@ -247,11 +268,10 @@ pub fn place_into(
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
let pad = n.next_multiple_of(4) - n;
if n + pad + 4 * full >= 4 * n {
return;
}
@@ -334,6 +354,22 @@ mod tests {
}
}
#[test]
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
let mut quad = top([0, 1, 2, 3]);
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
v.color = v.color & 0x00FF_FFFF | a << 24;
}
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
for (corner, v) in quad.iter().enumerate() {
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
}
// A partial face's blended occlusion falls between the steps.
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
assert!(pack_quad(&quad, TINT).is_none());
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
@@ -375,4 +411,13 @@ mod tests {
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
#[test]
fn the_records_end_on_a_whole_quad_of_slots() {
// Five whole faces, every one a record: the translucent vertices after them start a quad.
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
let out = layout(&solid, &[TINT; 5]);
assert_eq!(packed_quads(&out, 5), 5);
assert_eq!(out.len(), 8);
}
}
+35 -79
View File
@@ -18,6 +18,8 @@ use crate::{
ui,
};
mod shell_pages;
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
@@ -322,6 +324,8 @@ impl Opt {
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::PadRadialMode => "horizon.options.radial_mode",
Opt::PadRadialStick => "horizon.options.radial_stick",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -880,6 +884,7 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
@@ -1201,6 +1206,7 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.keyboard = all[usize::from(next(i, 3))];
}
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
_ => {}
}
if self.affects_preset() {
@@ -1352,6 +1358,11 @@ pub enum Page {
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
ControllerRadial,
ControllerRadialName,
/// The list of every action for one of its slots.
ControllerRadialPick,
Extras,
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
Pvp,
@@ -1683,6 +1694,10 @@ pub struct OptionsUi {
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
/// The radial menu's pages, on the settings shell.
radial: shell::Shell<crate::radial_editor::RadialScreen>,
/// The list of actions for one of the radial menu's slots.
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
@@ -1767,6 +1782,8 @@ impl Default for OptionsUi {
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
radial: Default::default(),
radial_pick: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
@@ -2112,6 +2129,7 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::ControllerRadialName => self.radial_name_typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2244,95 +2262,16 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
}
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
pub fn video_open(&self) -> bool {
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
@@ -2369,6 +2308,9 @@ impl OptionsUi {
"options.videoTitle"
}
Page::Controller | Page::PadBinds => "horizon.pages.controller",
Page::ControllerRadial => "horizon.pages.controller_radial",
Page::ControllerRadialName => "horizon.radial.name_title",
Page::ControllerRadialPick => "horizon.radial.pick_title",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
@@ -3602,6 +3544,9 @@ impl OptionsUi {
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
return;
}
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
// The keys lay the screen out at the size it was drawn at.
@@ -3953,6 +3898,9 @@ impl OptionsUi {
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.page() == Page::ControllerRadialName {
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
}
if self.shell().is_some() {
// Waiting on a controller button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4648,6 +4596,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if self.page() == Page::ControllerRadialName {
self.type_radial_name(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
@@ -4683,6 +4633,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
@@ -4786,6 +4739,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
return Vec::new();
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_key(s, key, text);
}
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
@@ -0,0 +1,302 @@
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
use super::{Event, OptionsUi, Page, tr};
use crate::{
gamepad,
hud::{HudAtlas, HudBatch},
radial_editor::Named,
settings::{Opt, Settings},
settings_shell as shell,
text::Text,
};
impl OptionsUi {
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
Page::ControllerRadial => Some(&self.radial),
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
Page::ControllerRadial => Some(&mut self.radial),
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
self.shell()?.opened_focus()
}
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
/// settings shell steps, rather than opens.
pub fn pad_clicks_step(&mut self, on: bool) {
if on {
if let Some(shell) = self.shell_mut() {
shell.clicks_step(true);
}
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
];
for shell in shells {
shell.clicks_step(false);
}
}
/// The controller's focus landed there.
pub fn clear_shell_opened_focus(&mut self) {
if let Some(shell) = self.shell_mut() {
shell.clear_opened_focus();
}
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
pub(super) fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
pub(super) fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
pub(super) fn shell_events(
&mut self,
s: &mut Settings,
outputs: Vec<shell::Output>,
) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
// A slot's list, for the slot, opening on what it holds.
if page == Page::ControllerRadialPick
&& let Some(at) = self.radial.screen.picking.take()
{
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
self.radial_pick.focus_soon(row);
}
}
shell::Output::Done => {
// Back from a slot's list, the focus is on the slot.
let slot = (self.page() == Page::ControllerRadialPick)
.then(|| self.radial_pick.screen.at());
events.extend(self.back(s));
if let Some((page, slot)) = slot {
self.radial
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
}
}
}
}
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
if self.radial_pick.screen.take_picked() {
self.radial.changed_elsewhere();
}
// A radial page added or deleted: the one to show now.
if let Some(page) = self.radial.screen.take_shown() {
self.radial.show(page);
}
events
}
/// What a press on a radial page's name does here.
pub(super) fn name_events(
&mut self,
named: crate::radial_editor::Named,
s: &mut Settings,
) -> Vec<Event> {
use crate::radial_editor::Named;
let mut events = vec![Event::ButtonClick];
match named {
Named::Nothing => return Vec::new(),
Named::Done => {
let entry = self.radial.screen.naming.take();
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
self.radial.changed_elsewhere();
}
events.push(Event::Changed);
events.extend(self.back(s));
}
Named::Back => events.extend(self.back(s)),
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
/// in place of a shell: whether it was.
pub(super) fn draw_radial_name(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) -> bool {
self.radial.screen.glyphs = self.pad_glyphs;
if self.page() == Page::ControllerRadialName
&& let Some(entry) = &mut self.radial.screen.naming
{
entry.draw(batch, atlas, cursor, in_world);
return true;
}
false
}
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
pub(super) fn click_radial_name(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
at: (f32, f32),
size: (f32, f32),
ignored: bool,
) -> Vec<Event> {
let entry = self.radial.screen.naming.as_mut();
let named = match entry.filter(|_| !ignored) {
Some(entry) => entry.click(atlas, at, size),
None => Named::Nothing,
};
self.name_events(named, s)
}
/// Whether a radial page's name is being typed.
pub(super) fn radial_name_typing(&self) -> bool {
self.radial
.screen
.naming
.as_ref()
.is_some_and(|n| n.typing())
}
/// Text typed into a radial page's name.
pub(super) fn type_radial_name(&mut self, text: &str) {
if let Some(entry) = &mut self.radial.screen.naming {
entry.type_text(text);
}
}
/// An edit shortcut in a radial page's name: whether it did something.
pub(super) fn radial_name_shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
let entry = self.radial.screen.naming.as_mut();
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
}
/// A key on a radial page's name entry.
pub(super) fn radial_name_key(
&mut self,
s: &mut Settings,
key: winit::keyboard::KeyCode,
text: Option<&str>,
) -> Vec<Event> {
let named = match &mut self.radial.screen.naming {
Some(entry) => entry.key(key, text),
// Nothing to name: Escape still leaves.
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
None => Named::Nothing,
};
self.name_events(named, s)
}
}
impl Opt {
/// The radial menu's rows of Controller Settings: their value.
pub(super) fn radial_text(self, s: &Settings) -> Text {
match self {
Opt::PadRadialMode => tr(match s.controller.radial_mode {
gamepad::RadialMode::Hold => "options.key.hold",
gamepad::RadialMode::Toggle => "options.key.toggle",
}),
Opt::PadRadialStick => tr(match s.controller.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
}),
_ => Text::empty(),
}
}
/// The radial menu's rows of Controller Settings: a step, back or on.
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
let next = |n: u8, count: u8| {
if back {
(n + count - 1) % count
} else {
(n + 1) % count
}
};
match self {
Opt::PadRadialMode => {
let all = gamepad::RadialMode::ALL;
let i = all
.iter()
.position(|m| *m == s.controller.radial_mode)
.unwrap_or(0) as u8;
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
}
Opt::PadRadialStick => {
let all = gamepad::RadialStick::ALL;
let i = all
.iter()
.position(|k| *k == s.controller.radial_stick)
.unwrap_or(0) as u8;
// An opener under the thumb that now selects goes unbound.
s.controller
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
}
_ => {}
}
}
}
@@ -124,21 +124,15 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
pack_palette::FluidFog {
color: fog.color,
radius: fog.radius,
fade: fog.fade,
}
}
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
fluid_fog: pack_palette::FluidFogs {
water: colors.fluid_fog.water.map(fluid),
lava: colors.fluid_fog.lava.map(fluid),
},
}
}
@@ -59,6 +59,24 @@ pub struct PackColors {
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
/// The fog the packs' fluid modifiers give water and lava.
pub fluid_fog: FluidFogs,
}
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
/// vanilla's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidFog {
pub color: Option<u32>,
pub radius: f32,
pub fade: f32,
}
/// The fog the packs' fluid modifiers give water and lava.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl Default for PackColors {
@@ -72,6 +90,7 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
+256 -13
View File
@@ -368,6 +368,57 @@ pub struct PackScreen {
browser_search: TextField,
/// The browser's search box has the keyboard.
browser_focus: bool,
/// Which atlas slot holds which icon, kept from one frame to the next
/// ([`PackScreen::bind_icons`]).
icon_slots: std::cell::RefCell<IconSlots>,
}
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
/// written into a slot, so an icon is sent once, when it takes a slot, and not
/// again every frame it is drawn.
#[derive(Clone, Debug, Default)]
struct IconSlots {
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
revision: u64,
/// Frames bound so far: an icon drawn in this one keeps its slot.
frame: u64,
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
/// frame it was last drawn in.
held: Vec<(String, usize, Arc<[u8]>, u64)>,
}
impl IconSlots {
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
fn begin(&mut self, atlas: &HudAtlas) {
if self.revision != atlas.revision {
self.held.clear();
self.revision = atlas.revision;
}
self.frame += 1;
}
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
let mut kept = self.held.remove(at);
let same = Arc::ptr_eq(&kept.2, icon);
kept.2 = icon.clone();
kept.3 = self.frame;
let slot = kept.1;
self.held.push(kept);
return Some((slot, !same));
}
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
Some(free) => free,
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
None => return None,
};
self.held
.push((texture.to_owned(), slot, icon.clone(), self.frame));
Some((slot, true))
}
}
fn tr(key: &str) -> Text {
@@ -459,6 +510,7 @@ impl PackScreen {
field
},
browser_focus: false,
icon_slots: Default::default(),
changed: false,
};
s.reposition(m);
@@ -931,10 +983,13 @@ impl PackScreen {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
/// writes the icons into them (vanilla registers a texture per pack). An
/// icon keeps its slot from one frame to the next and is sent when it takes
/// the slot, not every frame; the slot of the icon drawn longest ago is
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = 0..PACK_ICON_SLOTS;
let mut slots = self.icon_slots.borrow_mut();
slots.begin(atlas);
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
@@ -942,20 +997,23 @@ impl PackScreen {
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some(slot) = slots.next() else {
return;
let Some((slot, send)) = slots.slot_for(texture, icon) else {
continue;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
slots.held.pop();
return;
};
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
if send {
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
}
*texture = sprite;
}
}
@@ -1618,6 +1676,12 @@ impl PackScreen {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
// while the list shows it whole.
let mark = crate::widgets::targets_mark();
let size = OPTIONS_SIZE as f32;
crate::widgets::target([bx as f32, by as f32, size, size]);
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
@@ -1745,9 +1809,14 @@ mod icon_tests {
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself.
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
crate::widgets::end_frame(crate::widgets::generation() + 1);
let size = OPTIONS_SIZE as f32;
let button = [bx as f32, by as f32, size, size];
let targets = crate::widgets::last_targets();
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
@@ -2070,3 +2139,177 @@ mod footer_label_tests {
assert_eq!(lang::code(), before, "the global language was left alone");
}
}
#[cfg(test)]
mod icon_slot_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
fn packs(count: usize) -> Vec<PackInfo> {
let side = PACK_ICON_SIZE as usize;
(0..count)
.map(|i| PackInfo {
id: format!("file/pack{i:03}"),
title: Text::literal(format!("pack{i:03}")),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: Some(vec![i as u8; side * side * 4].into()),
required: false,
position: Position::Top,
fixed: false,
})
.collect()
}
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
/// (their texture keys before binding), the textures after, and the patches sent.
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
let m = Measure(&atlas.font);
let mut draws = screen.draw(&m, (0, 0), 0, 0);
let drawn: Vec<String> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
Some(texture.clone())
}
_ => None,
})
.collect();
let mut patches = Vec::new();
screen.bind_icons(&mut draws, atlas, &mut patches);
let bound = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
Some(texture.clone())
}
_ => None,
})
.collect();
(drawn, bound, patches)
}
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
tr("resourcePack.title"),
480,
height,
false,
packs(count),
&[],
)
}
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
#[test]
fn an_icon_is_sent_once_and_not_every_frame() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), 5, 400);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
for again in 0..3 {
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound, "frame {again}");
assert_eq!(resent.len(), 0, "frame {again}");
}
}
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
/// `unknown_pack`, and the next frame does not move them or send anything.
#[test]
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(
drawn.len(),
PACK_ICON_SLOTS + 6,
"the window shows every row"
);
assert_eq!(
(bound.len(), patches.len()),
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
);
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!((rebound, resent.len()), (bound, 0));
}
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
/// once on the way down, and a frame that draws what the last one drew sends nothing.
#[test]
fn scrolling_sends_only_the_icons_that_come_into_view() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 70, 330);
let mut seen = std::collections::HashSet::new();
let mut sent = 0;
// 150 wheel steps of 18 are more than the 2520 high list.
for step in 0..150 {
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
assert_eq!(patches.len(), new, "step {step}");
sent += patches.len();
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
screen.scroll(100.0, 200.0, -1.0);
}
assert_eq!((seen.len(), sent), (70, 70));
}
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
/// sent again when it comes back.
#[test]
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
.into_iter()
.map(|p| p.icon.unwrap())
.collect();
let mut slots = IconSlots::default();
let key = |i: usize| format!("k{i}");
slots.begin(&atlas());
for i in 0..PACK_ICON_SLOTS {
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
}
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
slots.frame += 1;
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
assert_eq!(
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
Some((1, true))
);
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
}
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
/// of the icons, so all are sent again.
#[test]
fn changed_pixels_and_a_new_atlas_send_again() {
let atlas = atlas();
let mut screen = screen(&Measure(&atlas.font), 3, 400);
let (_, bound, patches) = frame(&screen, &atlas);
assert_eq!(patches.len(), 3);
let key = icon_texture("file/pack001");
screen
.icons
.insert(key, vec![200; patches[1].rgba.len()].into());
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound);
assert_eq!(resent.len(), 1);
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
assert_eq!(resent[0].rgba[0], 200);
let other = self::atlas();
assert_ne!(other.revision, atlas.revision);
assert_eq!(frame(&screen, &other).2.len(), 3);
}
}
@@ -466,6 +466,13 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
/// serde_json rejects it.
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
first_json(bytes)
}
/// Reads the first JSON value from resource metadata, matching Gson's
/// `JsonReader` (which ignores a trailing signature or comment). Resource
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
.into_iter()
.next()?
+36 -98
View File
@@ -31,8 +31,10 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod simple;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -524,6 +526,8 @@ pub struct Particle {
no_render: bool,
/// Ticked once when created (`CritParticle` constructor).
pre_tick: bool,
/// A Polytone particle with physics, which joins only where its box is clear of blocks.
spawn_check: bool,
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
@@ -893,6 +897,7 @@ impl Particle {
behaviour: Behaviour::Base,
no_render: false,
pre_tick: false,
spawn_check: false,
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
@@ -1325,6 +1330,9 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
parallel_min: usize,
parallel_threads: usize,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
@@ -1527,6 +1535,8 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
parallel_min: PARALLEL_MIN,
parallel_threads: parallel_threads(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
@@ -1752,28 +1762,6 @@ impl ParticleEngine {
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
/// one), for the frame's additive pass.
pub fn take_additive(&self) -> Vec<Vertex> {
@@ -2121,23 +2109,7 @@ impl ParticleEngine {
}
/// `SingleQuadParticle.setSpriteFromAge`.
fn sprite_from_age(&self, p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = self.set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
sprite_from_age_in(&self.set_sprites, p);
}
#[allow(clippy::too_many_lines)]
/// `ParticleProvider.createParticle`: provider draws use the engine's
@@ -3630,13 +3602,14 @@ impl ParticleEngine {
self.sprite_from_age(&mut q);
q
}
/// ClientLevel.createFireworks: a Starter (or poofs without explosions).
/// ClientLevel.createFireworks: a Starter (or poofs without explosions) with `playSound`.
pub fn fireworks(
&mut self,
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
camera: [f64; 3],
play_sound: bool,
) {
if explosions.is_empty() {
for _ in 0..self.random.next_int_bound(3).unwrap_or(0) + 2 {
@@ -3660,17 +3633,17 @@ impl ParticleEngine {
life: 0,
lifetime,
twinkle_delay: twinkle,
far,
sound: play_sound.then_some(far),
});
}
fn tick_starters(&mut self) {
for mut s in std::mem::take(&mut self.starters) {
if s.life == 0 {
if s.life == 0 && s.sound.is_some() {
let large = s.explosions.len() >= 3
|| s.explosions
.iter()
.any(|e| e.shape == FireworkShape::LargeBall);
let sound = match (large, s.far) {
let sound = match (large, s.sound == Some(true)) {
(true, true) => "minecraft:entity.firework_rocket.large_blast_far",
(true, false) => "minecraft:entity.firework_rocket.large_blast",
(false, true) => "minecraft:entity.firework_rocket.blast_far",
@@ -3713,16 +3686,13 @@ impl ParticleEngine {
(c & 255) as f32 / 255.0,
((c >> 24) & 255) as f32 / 255.0,
];
self.create(
&ParticleOptions::colored(ParticleType::Flash, rgba),
s.at,
[0.0; 3],
);
let flash = ParticleOptions::colored(ParticleType::Flash, rgba);
self.create(&flash, s.at, [0.0; 3]);
}
s.life += 1;
if s.life > s.lifetime {
if s.twinkle_delay {
let sound = if s.far {
if s.twinkle_delay && s.sound.is_some() {
let sound = if s.sound == Some(true) {
"minecraft:entity.firework_rocket.twinkle_far"
} else {
"minecraft:entity.firework_rocket.twinkle"
@@ -3847,9 +3817,7 @@ impl ParticleEngine {
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
for p in &mut particles {
self.tick_particle(p, level);
}
self.tick_all(&mut particles, level);
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
@@ -3857,6 +3825,9 @@ impl ParticleEngine {
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
continue;
}
if p.pre_tick {
p.pre_tick = false;
self.tick_particle(&mut p, level);
@@ -3954,7 +3925,15 @@ impl ParticleEngine {
#[allow(clippy::too_many_lines)]
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Base
| Behaviour::Mod { .. }
| Behaviour::Animated
| Behaviour::Snowflake
| Behaviour::Crit
| Behaviour::Spark { .. } => {
let simple = tick_simple(&self.set_sprites, p, level);
self.finish_simple(p, simple);
}
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
@@ -3973,23 +3952,6 @@ impl ParticleEngine {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
self.sprite_from_age(p);
}
Behaviour::Animated => {
p.base_tick(level);
self.sprite_from_age(p);
}
Behaviour::Snowflake => {
p.base_tick(level);
self.sprite_from_age(p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
@@ -4000,11 +3962,6 @@ impl ParticleEngine {
);
}
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Drip {
fluid,
stage,
@@ -4475,27 +4432,6 @@ impl ParticleEngine {
}
}
}
Behaviour::Spark {
fade,
trail,
twinkle,
} => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
@@ -5100,7 +5036,9 @@ struct Starter {
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
/// `FireworkParticles.Starter.playSound`, with `isFarAwayFromCamera` when
/// it plays: `None` for a silent rocket.
sound: Option<bool>,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
num(crate::environment::polytone_noise().get(x, y))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -801,6 +801,26 @@ impl Host for Ctx<'_, '_> {
_ => Err(format!("{name} is not available here")),
}
}
/// A particle's own fields, as `particle_method` answers them.
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
use horizon_polytone::expr::Field as F;
let Subject::Particle(p) = &self.subject else {
return None;
};
if root != Root::Object {
return None;
}
Some(V::Num(match field {
F::Age => f64::from(p.age),
F::Life => f64::from(p.lifetime),
F::Custom => *self.custom,
F::X => p.position[0],
F::Y => p.position[1],
F::Z => p.position[2],
F::Temperature | F::Downfall => return None,
}))
}
}
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
@@ -1070,6 +1090,16 @@ const INITIALIZED: [&str; 13] = [
"dz",
];
/// Whether no block's collision shape overlaps `bb` (where the query fails, nothing does).
pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry::Aabb) -> bool {
let mut scene = crate::collisions::SceneCollisions {
world: level.world,
binding: level.collision,
subject: None,
};
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
}
impl super::ParticleController for Controller {
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
let mut update = ParticleUpdate::default();
@@ -1300,6 +1330,10 @@ pub(super) fn make(
particle.polytone_type =
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
// A particle with physics whose box starts inside a block's collision never joins the world:
// it is not drawn and none of its emitters run (measured on the mod: one spawned just under a
// block, where falling leaves start, never shows; one 0.01 clear of it does).
particle.spawn_check = def.has_physics;
particle.controller = Some(Box::new(Controller {
def: def.clone(),
registry: registry.clone(),
@@ -1511,6 +1545,31 @@ impl ParticleEngine {
}
}
impl ParticleEngine {
/// Sets (or clears) the resource packs' Polytone custom particle types.
pub fn set_polytone(&mut self, registry: Option<Arc<Registry>>) {
let registry = registry.filter(|r| !r.is_empty());
let same = match (&self.polytone, &registry) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2224,6 +2283,144 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// `scene`, with the torch a full block of collision: something a particle's box can start in.
fn solid_scene(blocks: &'static BlockRegistry) -> Scene {
use horizon_core::geometry::{Axis, VoxelShape};
let tag = |shape: VoxelShape| {
let sizes = shape.sizes();
let occupied = (0..sizes[2])
.flat_map(|z| {
(0..sizes[1]).flat_map(move |y| (0..sizes[0]).map(move |x| (x, y, z)))
})
.map(|(x, y, z)| u8::from(shape.is_full(x as i32, y as i32, z as i32)))
.collect();
let (min, max) = shape.discrete_bounds();
let axes = shape
.cube_coordinate_axes()
.iter()
.enumerate()
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i));
let mut fields = vec![
("occupied", Tag::ByteArray(occupied)),
("cube", Tag::Byte(i8::from(shape.is_cube()))),
("cube_axes", Tag::Byte(axes)),
("discrete_min", Tag::IntArray(min.to_vec())),
("discrete_max", Tag::IntArray(max.to_vec())),
];
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
let bits = shape.coordinates(axis).iter().map(|v| v.to_bits() as i64);
fields.push((name, Tag::LongArray(bits.collect())));
}
Tag::compound(fields)
};
let names = || Tag::List(STATES.iter().map(|s| Tag::String((*s).into())).collect());
let cached = CachedCollisionRegistry::from_nbt(
&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", names()),
(
"shapes",
Tag::List(vec![tag(VoxelShape::empty()), tag(VoxelShape::block())]),
),
("cached_shapes", Tag::IntArray(vec![0, 1])),
]),
blocks,
)
.unwrap();
Scene {
collision: BlockCollisionRegistry::new(Arc::new(cached)).unwrap(),
..scene(blocks)
}
}
#[test]
fn a_particle_with_physics_born_inside_a_block_never_joins() {
let blocks = blocks();
let leaf = |physics| {
serde_json::json!({"has_physics": physics, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]})
};
let root = pack(&[
(
"assets/test/polytone/custom_particles/leaf.json",
leaf(true),
),
(
"assets/test/polytone/custom_particles/ghost.json",
leaf(false),
),
(
"assets/test/polytone/custom_particles/mark.json",
serde_json::json!({"render_type": "invisible", "has_physics": false}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:leaf", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/wide.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": 10},
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/parent.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
"particle_emitters": [{"particle": "test:wide", "chance": "1", "y": "-0.05"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
serde_json::json!({"emitters": [{"particle": "test:parent", "chance": "1", "x": "-4.5"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/creeper.json",
serde_json::json!({"emitters": [{"particle": "test:wide", "chance": "1", "x": "-4.5"}]}),
),
(
"assets/minecraft/polytone/entity_modifiers/zombie.json",
serde_json::json!({"emitters": [{"particle": "test:ghost", "chance": "1"}]}),
),
]);
let scene = solid_scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The solid block is at (1, 64, 1): its bottom face is y 64.
// Spawns one particle, lets the engine take it in and tick it: whether it joined, and
// how many particles it emitted.
let mut spawn = |entity: &str, y: f64| {
engine.particles.clear();
engine.pending.clear();
engine.polytone_entity(&level, entity, [1.5, y, 1.5]);
engine.tick(&level);
let joined = engine.particles.len();
engine.tick(&level);
(
joined,
engine.particles.len() + engine.pending.len() - joined,
)
};
// The solid block is at (1, 64, 1): its bottom face is y 64.
// 0.05 under the block, a 0.08 box reaches into it: it never joins, nothing emitted.
assert_eq!(spawn("minecraft:player", 63.95), (0, 0));
// 0.01 clear of it, it joins and emits.
assert_eq!(spawn("minecraft:player", 63.91), (1, 1));
// Without physics nothing collides.
assert_eq!(spawn("minecraft:zombie", 63.95), (1, 1));
// A wide box reaching a block from five blocks away does not join either.
assert_eq!(spawn("minecraft:creeper", 60.0), (0, 0));
// Nor does one a clear particle emits there: the small one joins, its wide copy never.
assert_eq!(spawn("minecraft:skeleton", 60.0), (1, 0));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
@@ -0,0 +1,161 @@
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
//! threads in a busy scene.
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
pub(super) const PARALLEL_MIN: usize = 2048;
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
pub(super) fn parallel_threads() -> usize {
(horizon_threads::worker_count() / 2).clamp(1, 4)
}
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
pub(super) fn tick_simple(
set_sprites: &[Vec<u32>],
p: &mut Particle,
level: &ParticleLevel,
) -> Simple {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Animated => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Snowflake => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Spark { fade, trail, .. } => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
return Simple::SparkTrail;
}
}
_ => return Simple::No,
}
Simple::Done
}
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
///
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
ParticleLevel {
world: level.world,
facts: level.facts,
collision: level.collision,
player: level.player,
cached_chunk: std::cell::Cell::new(None),
}
}
/// What `tick_simple` did with a particle.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Simple {
/// Not one of its behaviours: the engine ticks it.
No,
/// Ticked completely.
Done,
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
/// it takes the next seed, so the children come in the order the sparks have.
SparkTrail,
}
impl ParticleEngine {
/// What `tick_simple` leaves to the engine: a spark's trail child.
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
if simple != Simple::SparkTrail {
return;
}
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
/// other and of the engine, so with enough of them they tick on several threads; every other
/// one then ticks here, in the order it had, so the particles, the engine's random and what
/// is spawned come out as when all of them ticked one after another.
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
{
for p in particles {
self.tick_particle(p, level);
}
return;
}
let sets = &self.set_sprites;
let per = particles.len().div_ceil(self.parallel_threads);
let mut handled = vec![Simple::No; particles.len()];
std::thread::scope(|scope| {
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
for (p, done) in part.iter_mut().zip(done) {
*done = tick_simple(sets, p, level);
}
};
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
let own = parts.next();
for (part, done) in parts {
let view = thread_view(level);
scope.spawn(move || run(part, done, &view));
}
// This thread takes the first share, instead of waiting for the others.
if let Some((part, done)) = own {
run(part, done, level);
}
});
for (p, done) in particles.iter_mut().zip(handled) {
match done {
Simple::No => self.tick_particle(p, level),
simple => self.finish_simple(p, simple),
}
}
}
}
@@ -1,8 +1,12 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
//! The particle stress scene's load on `ParticleEngine`, outside the game.
//!
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
//! and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
@@ -25,6 +29,7 @@ impl Lcg {
}
}
/// What to spawn each tick: a type, how many, around where, how far, how fast.
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
@@ -38,7 +43,7 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
twinkle: false,
};
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera, true);
}
for &(ty, count, center, delta, speed) in types {
let options = ParticleOptions::simple(ty);
@@ -58,9 +63,29 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
}
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
fn emitters() -> [Emitter; 5] {
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
[
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
(
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
),
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
]
}
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
/// without prepared resources.
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -88,69 +113,127 @@ fn particle_tick_cost() {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
let level = ParticleLevel {
f(&ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
});
}
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
engine.parallel_min = parallel_min;
engine.parallel_threads = threads;
engine
}
/// Every live particle's fields, as text.
fn state(engine: &ParticleEngine) -> Vec<String> {
engine
.particles
.iter()
.map(|p| format!("{:?}", engine.snapshot(p)))
.collect()
}
#[test]
fn parallel_tick_matches_serial_tick() {
with_scene(|level| {
let types = emitters();
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
let mut serial = engine(usize::MAX, 1);
let mut threaded = engine(0, 3);
for e in [&mut serial, &mut threaded] {
e.seed_randoms(7, 8, 9);
}
let (mut a, mut b) = (Lcg(99), Lcg(99));
for tick in 0..90 {
spawn_tick(&mut serial, &mut a, &types, 3);
spawn_tick(&mut threaded, &mut b, &types, 3);
serial.tick(level);
threaded.tick(level);
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
if tick % 10 == 9 {
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
}
}
assert!(serial.len() > 1000, "{} particles", serial.len());
assert_eq!(serial.sounds.len(), threaded.sounds.len());
assert_eq!(serial.next_randoms(), threaded.next_randoms());
});
}
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
fn measure(
level: &ParticleLevel,
name: &str,
(types, rockets): (&[Emitter], usize),
(parallel_min, threads): (usize, usize),
secs: f64,
) {
let mut engine = engine(parallel_min, threads);
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, types, rockets);
engine.tick(level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
with_scene(|level| {
let [flame, cosy, rod, poof, smoke] = emitters();
let all = [flame, cosy, rod, poof, smoke];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
let one = (usize::MAX, 1);
eprintln!("default threads: {}", parallel_threads());
for (name, types, rockets) in [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
] {
measure(level, name, (&types, rockets), one, secs);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
for threads in [2, 3, 4, 6] {
measure(
level,
&format!("whole scene, {threads} threads"),
(&all, 3),
(PARALLEL_MIN, threads),
secs,
);
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
});
}
+152 -32
View File
@@ -7,7 +7,10 @@
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
//! a weak reference tells an edited or replaced section from the one the list
//! was built from (see `random_tick_sections`), without keeping its blocks.
use crate::{chunk::Section, fast_hash::FxHashMap};
use crate::{
chunk::{Chunk, Section},
fast_hash::FxHashMap,
};
use std::sync::{Arc, Mutex, Weak};
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
@@ -18,13 +21,16 @@ struct Cached<T> {
section: Weak<Section>,
pois: SectionPois<T>,
}
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
/// index: a search through a column looks it up once.
pub struct PoiSections<T> {
sections: Mutex<Lists<T>>,
}
struct Lists<T> {
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
/// Size above which lists of sections no longer resident are dropped.
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
/// How many section lists are kept, and the count above which the lists
/// of sections no longer resident are dropped.
len: usize,
prune_at: usize,
}
const MIN_PRUNE: usize = 4096;
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
fn default() -> Self {
Self {
sections: Mutex::new(Lists {
by_section: FxHashMap::default(),
by_column: FxHashMap::default(),
len: 0,
prune_at: MIN_PRUNE,
}),
}
}
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, whose block states `kind` classifies. `kind`
/// must give the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = lists.by_section.get(&key)
&& cached.section.as_ptr() == Arc::as_ptr(section)
{
return cached.pois.clone();
impl<T: Copy> Lists<T> {
/// The column's lists, for sections up to `last`, after dropping the
/// lists of sections no longer resident if there are too many.
fn column(
&mut self,
column: (i32, i32),
last: usize,
) -> (&mut [Option<Cached<T>>], &mut usize) {
if self.len >= self.prune_at {
self.prune();
}
let lists = self.by_column.entry(column).or_default();
if lists.len() <= last {
lists.resize_with(last + 1, || None);
}
(lists, &mut self.len)
}
/// Drops the lists of sections no longer resident.
fn prune(&mut self) {
let mut len = 0;
self.by_column.retain(|_, lists| {
for c in lists.iter_mut() {
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
*c = None;
}
}
let kept = lists.iter().filter(|c| c.is_some()).count();
len += kept;
kept > 0
});
self.len = len;
self.prune_at = (len * 2).max(MIN_PRUNE);
}
}
/// The POIs of `section` from `slot`, listed again (and kept there) unless
/// `slot` was listed from this very section; `len` counts the slots filled.
fn list<'a, T: Copy>(
slot: &'a mut Option<Cached<T>>,
len: &mut usize,
section: &Arc<Section>,
kind: &mut impl FnMut(u32) -> Option<T>,
) -> &'a SectionPois<T> {
let fresh = slot
.as_ref()
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
if !fresh {
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
let mut states = [0u32; 4096];
section.unpack(&mut states);
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
} else {
Arc::from([])
};
if lists.by_section.len() >= lists.prune_at {
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
*len += usize::from(slot.is_none());
*slot = Some(Cached {
section: Arc::downgrade(section),
pois,
});
}
&slot.as_ref().expect("just listed").pois
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, the `key.1`-th section of the column
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
/// the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let s = usize::try_from(key.1).expect("a section index");
let (column, len) = lists.column((key.0, key.2), s);
list(&mut column[s], len, section, &mut kind).clone()
}
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
/// column.
pub fn column(
&self,
chunk: &Chunk,
min_y: i32,
(y0, y1): (i32, i32),
mut kind: impl FnMut(u32) -> Option<T>,
out: &mut Vec<(i32, SectionPois<T>)>,
) {
out.clear();
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
if first > last {
return;
}
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
for s in first as usize..=last as usize {
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
if !pois.is_empty() {
out.push((min_y + s as i32 * 16, pois.clone()));
}
}
lists.by_section.insert(
key,
Cached {
section: Arc::downgrade(section),
pois: pois.clone(),
},
);
pois
}
}
@@ -143,6 +221,48 @@ mod tests {
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
}
/// A column gives each section's list as `get` does, the sections
/// without any left out, and keeps them until a section changes.
#[test]
fn a_column_gives_what_each_section_gives() {
let index = PoiSections::<u8>::default();
let kind = |v: u32| (v == 5).then_some(5);
let mut chunk = Arc::new(Chunk::empty(2, -3));
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
Arc::make_mut(&mut chunk).set(x, y, z, 5);
}
let mut out = Vec::new();
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
for (base, pois) in &out {
let s = (base - MIN_Y) / 16;
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
assert!(Arc::ptr_eq(pois, &one));
}
// Kept: no rescan.
index.column(
&chunk,
MIN_Y,
(MIN_Y, MIN_Y + 63),
|_| panic!("rescanned"),
&mut out,
);
assert_eq!(out.len(), 2);
// An edit lists that section again.
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
let mut rescans = 0;
let counted = |v: u32| {
rescans += 1;
kind(v)
};
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
assert!(rescans > 0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, MIN_Y + 32);
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
}
#[test]
fn unloaded_sections_are_dropped() {
let index = PoiSections::<u8>::default();
@@ -153,7 +273,7 @@ mod tests {
index.get((1, i, 0), &Arc::new(Section::default()), kind);
}
let lists = index.sections.lock().unwrap();
assert!(lists.by_section.len() <= 2);
assert!(lists.by_section.contains_key(&(0, 0, 0)));
assert!(lists.len <= 2);
assert!(lists.by_column[&(0, 0)][0].is_some());
}
}
@@ -7,13 +7,16 @@ use horizon_core::{
geometry::{Vec3, traverse_blocks},
position::BlockPos,
};
use horizon_world::collision_context::CollisionContext;
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
/// with no collision shape.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
/// with no collision shape for `CollisionContext.positionContext(context_y)`.
/// Vanilla passes the height of the block under the column it reads, so
/// scaffolding seen from below lets the gas and the geyser through.
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3], context_y: i32) -> bool {
let state = level.world.block(pos[0], pos[1], pos[2]);
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
return true;
@@ -23,7 +26,9 @@ pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
binding: level.collision,
subject: None,
};
match level.collision.collision_shape(&mut scene, state, pos) {
let context = CollisionContext::position(f64::from(context_y));
let position = BlockPos::new(pos[0], pos[1], pos[2]);
match (level.collision).collision_shape_with_context(&mut scene, state, position, &context) {
Ok(shape) => shape.is_empty(),
Err(_) => !level.facts.get(state).full_collision,
}
@@ -46,9 +51,9 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
let state = level.world.block(pos[0], pos[1], pos[2]);
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
if !is_water_source(level, pos)
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|| (!is_water_block && !is_geyser_passable_block(level, pos, origin[1]))
{
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
if level.facts.is_air(state) || is_geyser_passable_block(level, pos, origin[1]) {
return Some(pos);
}
break;
@@ -63,7 +68,7 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
let geyser_force_height = 6 * water_blocks;
for i in 0..geyser_force_height {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]], pos[1] - 1) {
return i;
}
}
@@ -79,7 +84,7 @@ pub fn can_be_reached_by_noxious_gas(
pos: [f64; 3],
) -> bool {
let block = ParticleLevel::block_at(pos);
if !is_geyser_passable_block(level, block) {
if !is_geyser_passable_block(level, block, block[1] - 1) {
return false;
}
let center = source_block.map(|c| f64::from(c) + 0.5);
@@ -0,0 +1,844 @@
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
//! page's own name is typed on a small page of its own, [`NameEntry`].
use crate::{
gamepad::{self, GlyphOf, MenuJob},
graphics_style::{self as style, BUTTON_H},
hud::{HudAtlas, HudBatch},
lang,
options_ui::{Event, Page},
pad_glyph::{Glyph, Glyphs},
radial::{self, Pages, RadialAction, SLOTS, Slot},
radial_overlay::{page_label, slot_caption},
settings::Settings,
settings_shell::{
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
},
text::Text,
ui,
};
const TITLE: &str = "horizon.pages.controller_radial";
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
const PAGE_GROUP: &str = "horizon.radial.group.page";
const ALL_GROUP: &str = "horizon.radial.group.all";
/// The opener's row binds the menu's own pad action, in place.
const OPENER: &str = gamepad::RADIAL;
/// A row of a page; `usize`s are the page and the slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RadialRow {
Slot(usize, usize),
Rename(usize),
NewPage,
Delete(usize),
Reset,
Opener,
}
/// A slot's action, that it holds none, or that its action is missing.
fn slot_label(slot: &Slot) -> String {
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
}
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
/// A missing action steps as Empty does.
fn step(slot: &Slot, back: bool) -> Slot {
let order: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.collect();
let at = order
.iter()
.position(|entry| *entry == slot.action())
.unwrap_or(0);
let next = if back { at + order.len() - 1 } else { at + 1 };
order[next % order.len()].into()
}
fn page_of(page: usize, pages: usize) -> String {
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
}
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
/// strip says what stands in the way (no button, or one another action shares); while it waits,
/// the shell says how to bind it.
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
let pad = &s.controller;
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
let warning = match pad.binding(OPENER) {
None => Some(lang::get("horizon.radial.unbound")),
Some(_) => pad
.conflicts(OPENER)
.then(|| lang::get("horizon.radial.conflict")),
};
let binding = Binding {
capture: Capture::Pad(OPENER),
shown: Shown::Pad {
glyph,
cancel: Glyph::menu(MenuJob::Start, glyphs),
},
shared: Vec::new(),
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
reset: Reset::None,
};
let name = lang::get("horizon.radial.opener");
Row {
chosen: warning,
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
.about(lang::get(opener_about(pad.radial_stick)))
}
}
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
match stick {
gamepad::RadialStick::Right => "horizon.radial.opener.about",
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
}
}
/// The page's rows: its slots, then what changes it.
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
let slots = (0..SLOTS).map(|slot| {
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
let cell = pages.page(page).slot(slot);
let value = Value::Choice(slot_label(cell));
let missing = match cell {
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
_ => None,
};
Row {
chosen: missing,
..Row::new(RadialRow::Slot(page, slot), name, value)
.about(lang::get("horizon.radial.slot.about"))
}
});
let full = (pages.len() >= radial::MAX_PAGES)
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
let changes = vec![
Row::new(
RadialRow::Rename(page),
lang::get("horizon.radial.rename"),
Value::Press,
)
.about(lang::get("horizon.radial.rename.about")),
Row::new(
RadialRow::NewPage,
lang::get(radial::NEW_PAGE_NAME),
Value::Press,
)
.about(lang::get("horizon.radial.new_page.about"))
.blocked(full),
Row::new(
RadialRow::Delete(page),
lang::get("horizon.radial.delete_page"),
Value::Press,
)
.about(lang::get("horizon.radial.delete_page.about"))
.blocked(last),
];
[
Group {
name: SLOTS_GROUP,
rows: slots.collect(),
},
Group {
name: PAGE_GROUP,
rows: changes,
},
]
}
/// The screen's own state; the pages are the player's settings.
#[derive(Default)]
pub(crate) struct RadialScreen {
/// The buttons' names follow the controller in use: the options screen says which.
pub(crate) glyphs: Glyphs,
/// The page to show next: one just added, or where a deleted one was.
show: Option<usize>,
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
pub(crate) naming: Option<NameEntry>,
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
pub(crate) picking: Option<(usize, usize)>,
}
impl RadialScreen {
/// The page the shell should show now, once.
pub(crate) fn take_shown(&mut self) -> Option<usize> {
self.show.take()
}
}
impl Screen for RadialScreen {
type Page = usize;
type Row = RadialRow;
fn title(&self) -> &'static str {
TITLE
}
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
/// named with.
fn stamp(&self, s: &Settings) -> u64 {
let pad = &s.controller;
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
let pages = &s.controller.radial;
let default = *pages == Pages::default();
let all = Group {
name: ALL_GROUP,
rows: vec![
Row::new(
RadialRow::Reset,
lang::get("horizon.radial.reset"),
Value::Press,
)
.about(lang::get("horizon.radial.reset.about"))
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
opener_row(s, self.glyphs),
],
};
(0..pages.len())
.map(|page| {
let [slots, changes] = page_groups(pages, page);
let all = Group {
name: all.name,
rows: all.rows.clone(),
};
Sheet::new(
page,
page_label(pages.page(page)),
page_of(page, pages.len()),
vec![slots, changes, all],
)
})
.collect()
}
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
let pages = &mut s.controller.radial;
match row {
RadialRow::Slot(page, slot) => {
let next = step(pages.page(page).slot(slot), back);
pages.set_slot(page, slot, next);
}
RadialRow::Rename(page) => {
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
return vec![Output::Push(Page::ControllerRadialName)];
}
RadialRow::NewPage => self.show = pages.add(),
RadialRow::Delete(page) => {
pages.remove(page);
self.show = Some(pages.clamp(page));
}
RadialRow::Reset => {
*pages = Pages::default();
self.show = Some(0);
}
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
RadialRow::Opener => return Vec::new(),
}
vec![Output::Event(Event::Changed)]
}
/// A press on a slot opens the list of every action for it; Left and Right still step it.
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
let RadialRow::Slot(page, slot) = row else {
return None;
};
self.picking = Some((page, slot));
Some(vec![Output::Push(Page::ControllerRadialPick)])
}
/// The wheel over a slot steps it, as a press does.
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
}
/// Undo is how a delete or a reset is taken back: they act at once.
fn undoes(&self) -> bool {
true
}
/// The pages, and the opener's button, which its row binds.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller.radial = before.controller.radial.clone();
s.controller.bind(OPENER, before.controller.binding(OPENER));
Vec::new()
}
}
const PICK_TITLE: &str = "horizon.radial.pick_title";
const PICK_GROUP: &str = "horizon.radial.group.actions";
/// A row of a slot's list: the line naming the slot, then Empty and every action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PickRow {
Slot,
Pick(Option<RadialAction>),
}
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
#[derive(Default)]
pub(crate) struct PickScreen {
/// The page and the slot.
at: (usize, usize),
picked: bool,
}
impl PickScreen {
/// For `at`; the row the focus opens on, what the slot holds.
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
*self = Self { at, picked: false };
self.current(pages).unwrap_or(PickRow::Pick(None))
}
/// The page and the slot it is for.
pub(crate) fn at(&self) -> (usize, usize) {
self.at
}
/// A pick changed the pages since the last call, which the editor's Undo takes back.
pub(crate) fn take_picked(&mut self) -> bool {
std::mem::take(&mut self.picked)
}
/// The row of what the slot holds; none for an action this game doesn't know.
fn current(&self, pages: &Pages) -> Option<PickRow> {
match pages.page(self.at.0).slot(self.at.1) {
Slot::Empty => Some(PickRow::Pick(None)),
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
Slot::Missing(_) => None,
}
}
}
impl Screen for PickScreen {
type Page = ();
type Row = PickRow;
fn title(&self) -> &'static str {
PICK_TITLE
}
fn stamp(&self, s: &Settings) -> u64 {
let (page, slot) = self.at;
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
let pages = &s.controller.radial;
let (page, slot) = self.at;
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
let named = lang::tr(
"horizon.radial.pick.slot",
&[&direction, &page_label(pages.page(page))],
);
let current = self.current(pages);
let about = lang::get("horizon.radial.pick.about");
let mut rows =
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
let id = PickRow::Pick(action);
let value = if current == Some(id) {
Value::Choice(lang::get("horizon.radial.pick.current"))
} else {
Value::Press
};
let name = slot_label(&action.into());
rows.push(Row::new(id, name, value).about(about.clone()));
}
vec![Sheet::new(
(),
named,
about,
vec![Group {
name: PICK_GROUP,
rows,
}],
)]
}
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
let PickRow::Pick(action) = row else {
return Vec::new();
};
let (page, slot) = self.at;
s.controller.radial.set_slot(page, slot, action.into());
self.picked = true;
vec![Output::Event(Event::Changed), Output::Done]
}
}
/// The name field and its buttons, centred in the screen but under the title, one row apart.
const NAME_W: f32 = 200.0;
const NAME_BLOCK_H: f32 = 100.0;
const NAME_TOP_MIN: f32 = 30.0;
const NAME_STEP: f32 = 25.0;
/// What a press on the name's page did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Named {
Nothing,
/// Done: the page takes the name.
Done,
/// Cancel, keeping the old name.
Back,
}
/// A page's name being typed.
pub(crate) struct NameEntry {
page: usize,
field: ui::EditBox,
}
impl NameEntry {
fn new(page: usize, name: &str) -> Self {
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
field.max_length = radial::NAME_MAX;
field.set_value(name);
field.focused = true;
Self { page, field }
}
/// Done and Cancel, under the field.
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
}
pub(crate) fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(lang::get("horizon.radial.name_title"));
style::title(batch, atlas, &title);
let field = &mut self.field;
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
field.draw(batch, atlas, None);
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
let (label, action) = match named {
Named::Done => ("gui.done", crate::widgets::Action::Done),
_ => ("gui.cancel", crate::widgets::Action::Cancel),
};
let hover = ui::hovered(cursor, x, y, bw, bh);
let text = Text::literal(lang::get(label));
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
}
}
/// A press at `(x, y)` on a screen `w` x `h`.
pub(crate) fn click(
&mut self,
atlas: &HudAtlas,
(x, y): (f32, f32),
(w, h): (f32, f32),
) -> Named {
let hit = Self::buttons(w, h)
.into_iter()
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
if let Some((_, named)) = hit {
return named;
}
let field = &mut self.field;
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
if field.focused {
field.click(atlas, x, false, false);
}
Named::Nothing
}
/// The name field takes the keys.
pub(crate) fn typing(&self) -> bool {
self.field.focused
}
pub(crate) fn type_text(&mut self, text: &str) {
if self.field.focused {
self.field.insert_text(text);
}
}
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
use winit::keyboard::KeyCode as K;
match key {
K::Escape => return Named::Back,
K::Enter | K::NumpadEnter => return Named::Done,
_ if self.field.focused => {
if !self.field.key(key, false, false)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
self.field.insert_text(&typed);
}
}
_ => {}
}
Named::Nothing
}
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
pub(crate) fn shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
if self.field.focused {
self.field.shortcut(shortcut, clipboard);
}
self.field.focused
}
/// The name into the pages. Left as it was, a default page keeps following the language.
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
let changed = self.field.value != page_label(pages.page(self.page));
if changed {
pages.rename(self.page, &self.field.value);
}
changed
}
}
/// The name field's top.
fn name_top(h: f32) -> f32 {
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gamepad::Button as PadButton;
use crate::radial::PageName;
use crate::settings_shell::{Hosted, Shell};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
let sheets = RadialScreen::default().sheets(s);
let groups = sheets.into_iter().nth(page).unwrap().groups;
groups.into_iter().flat_map(|group| group.rows).collect()
}
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
let page = match id {
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
_ => 0,
};
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
}
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
let at = shell.point_at(s, SIZE, id);
shell.click(s, at, SIZE, false, false, false)
}
/// Undo, at the foot over Done.
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
}
#[test]
fn a_slot_steps_through_empty_and_every_action_both_ways() {
let mut seen = vec![Slot::Empty];
let mut slot = Slot::Empty;
for _ in RadialAction::ALL {
slot = step(&slot, false);
seen.push(slot.clone());
}
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
assert_eq!(actions, RadialAction::ALL.to_vec());
assert_eq!(
step(&Slot::Empty, true).action(),
RadialAction::ALL.last().copied()
);
let chat = Slot::Action(RadialAction::Chat);
assert_eq!(step(&step(&chat, true), false), chat);
// A missing action steps on as Empty would.
let missing = Slot::Missing("mymod:dash".to_owned());
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
}
#[test]
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.clone();
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
assert_eq!(shell.screen.picking, Some((0, 2)));
assert_eq!(
s.controller.radial, before,
"the press itself changes nothing"
);
// Right on the focused row steps it.
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
let stepped = step(before.page(0).slot(2), false);
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
}
#[test]
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
let mut s = Settings::default();
let mut picker = PickScreen::default();
// General's right slot holds Commands.
let opens_on = picker.open_on((0, 2), &s.controller.radial);
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
let rows: Vec<_> = picker
.sheets(&s)
.into_iter()
.flat_map(|sheet| sheet.groups)
.flat_map(|group| group.rows)
.collect();
assert_eq!(rows[0].id, PickRow::Slot);
assert_eq!(rows[0].value, Value::Note);
// Empty, then every action, each once; only the slot's own marked.
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
let every: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.map(PickRow::Pick)
.collect();
assert_eq!(picks, every);
let marked: Vec<_> = rows
.iter()
.filter(|row| matches!(row.value, Value::Choice(_)))
.map(|row| row.id)
.collect();
assert_eq!(marked, [opens_on]);
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
assert_eq!(
*s.controller.radial.page(0).slot(2),
Slot::Action(RadialAction::Chat)
);
assert!(picker.take_picked() && !picker.take_picked());
// Empty empties it.
picker.press(&mut s, PickRow::Pick(None), false);
assert!(s.controller.radial.page(0).slot(2).is_empty());
}
#[test]
fn a_missing_action_says_which_it_is() {
let mut s = Settings::default();
s.controller
.radial
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
let english = lang::Language::load(&[], "en_us");
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
}
#[test]
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.page(0).clone();
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert!(outputs.contains(&Output::Event(Event::Changed)));
let after = s.controller.radial.page(0);
assert_eq!(*after.slot(0), step(before.slot(0), false));
assert_eq!(after.slots()[1..], before.slots()[1..]);
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, Pages::default());
assert!(!shell.changed());
}
#[test]
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let found = s.controller.radial.clone();
let mut shell = Shell::<RadialScreen>::default();
click(&mut shell, &mut s, RadialRow::Delete(0));
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
// One page left: it can't go, and the strip says why.
let delete = row(&s, RadialRow::Delete(0));
assert!(!delete.live && delete.why.is_some());
let reset = row(&s, RadialRow::Reset);
assert!(reset.live);
shell.screen.take_shown();
click(&mut shell, &mut s, RadialRow::Reset);
assert_eq!(s.controller.radial, Pages::default());
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
assert_eq!(shell.screen.take_shown(), Some(0));
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, found);
}
#[test]
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
while row(&s, RadialRow::NewPage).live {
screen.press(&mut s, RadialRow::NewPage, false);
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
}
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
assert!(row(&s, RadialRow::NewPage).why.is_some());
// Every page is a sheet, named as the overlay names it.
let sheets = screen.sheets(&s);
assert_eq!(sheets.len(), radial::MAX_PAGES);
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
}
#[test]
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
screen.press(&mut s, RadialRow::Delete(1), false);
assert_eq!(screen.take_shown(), Some(0));
}
#[test]
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
let general = Pages::default().page(0).name.clone();
let mut s = Settings::default();
let mut screen = RadialScreen::default();
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
let entry = screen.naming.as_mut().unwrap();
// Done with the name as it was: a default page keeps following the language.
assert_eq!(entry.key(K::Enter, None), Named::Done);
assert!(!entry.apply(&mut s.controller.radial));
assert_eq!(s.controller.radial.page(0).name, general);
entry.type_text("!");
assert_eq!(entry.key(K::Escape, None), Named::Back);
entry.field.set_value("Mine");
entry.type_text("!");
assert!(entry.apply(&mut s.controller.radial));
assert_eq!(
s.controller.radial.page(0).name,
PageName::Custom("Mine!".to_owned())
);
}
#[test]
fn undo_puts_back_a_name_given_on_the_name_page() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let mut shell = Shell::<RadialScreen>::default();
shell.show(1);
click(&mut shell, &mut s, RadialRow::Rename(1));
let entry = shell.screen.naming.as_mut().unwrap();
entry.field.set_value("Building");
// As the options screen does on Done: the name page changed what Undo puts back.
let entry = shell.screen.naming.take().unwrap();
assert!(entry.apply(&mut s.controller.radial));
shell.changed_elsewhere();
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
assert!(!shell.changed());
}
#[test]
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
let mut s = Settings::default();
// Selecting with the left stick, the top face button (North) can open it.
s.controller.radial_stick = gamepad::RadialStick::Left;
s.controller.bind(OPENER, Some(gamepad::Button::North));
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
let label = |s: &Settings| {
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
match opener_row(s, glyphs).value {
Value::Binding(Binding {
shown: Shown::Pad { glyph, .. },
..
}) => glyph.expect("bound").label().to_owned(),
other => panic!("{other:?}"),
}
};
// By places, the top button: printed X on a Nintendo pad.
s.controller.nintendo_letters = false;
assert_eq!(label(&s), "X");
// By letters, the part is played by the button printed Y (on the left).
s.controller.nintendo_letters = true;
assert_eq!(label(&s), "Y");
}
#[test]
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
let mut s = Settings::default();
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
let shown = |s: &Settings| match opener_row(s, glyphs).value {
Value::Binding(Binding {
capture,
shown: Shown::Pad { glyph, .. },
..
}) => {
assert_eq!(capture, Capture::Pad(OPENER));
glyph
}
other => panic!("{other:?}"),
};
let bound = s.controller.binding(OPENER).unwrap();
// The button on its glyph.
let glyph = shown(&s).expect("the opener's glyph");
assert_eq!(glyph.part(), bound);
assert_eq!(glyph.label(), "→");
assert_eq!(opener_row(&s, glyphs).chosen, None);
s.controller.bind("key.chat", Some(bound));
let shared = opener_row(&s, glyphs).chosen;
assert!(shared.is_some(), "a shared button");
s.controller.bind(OPENER, None);
let unbound = opener_row(&s, glyphs).chosen;
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
// A press waits for a button on the row, and Undo takes a new one back.
let mut shell = Shell::<RadialScreen>::default();
s.controller.bind(OPENER, Some(bound));
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
assert!(shell.changed());
s.controller.bind(OPENER, Some(PadButton::North));
undo(&mut shell, &mut s);
assert_eq!(s.controller.binding(OPENER), Some(bound));
}
#[test]
fn every_string_has_a_horizon_key() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let english = lang::Language::load(&[], "en_us");
let ((), missed) = lang::scoped(english, || {
let mut s = Settings::default();
s.controller.bind("key.chat", Some(PadButton::DPadRight));
let mut shell = Shell::<RadialScreen>::default();
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
let mut batch = HudBatch::new([854, 480]);
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
};
// Every page's rows are built, with what the strip says of each.
draw(&mut shell, &s);
s.controller.bind(OPENER, None);
s.controller.radial.remove(1);
while s.controller.radial.add().is_some() {}
draw(&mut shell, &s);
s.controller.radial = Pages::default();
s.controller.radial.remove(1);
draw(&mut shell, &s);
let mut batch = HudBatch::new([854, 480]);
let waiting = Some(Capture::Pad(OPENER));
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
let mut entry = NameEntry::new(0, "Mine");
let mut batch = HudBatch::new([854, 480]);
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
let mut picker = Shell::<PickScreen>::default();
picker.screen.open_on((0, 1), &s.controller.radial);
let mut batch = HudBatch::new([854, 480]);
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
});
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
let missed: Vec<_> = missed
.iter()
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
.collect();
assert!(missed.is_empty(), "no text for {missed:?}");
}
}
File diff suppressed because it is too large. Load diff
+36 -1
View File
@@ -232,6 +232,8 @@ impl Selector {
pub struct Tick {
/// A vibration was chosen: its travel time (the particle starts).
pub started: Option<(Info, i32)>,
/// A vibration was reloaded from save: particle resumes from interpolated pos.
pub resumed: Option<(Info, i32)>,
/// A vibration arrived.
pub received: Option<Info>,
/// `onDataChanged`.
@@ -261,6 +263,10 @@ impl Data {
self.selector.start_over();
}
if let Some(info) = self.current {
if self.reload_particle {
self.reload_particle = false;
out.resumed = Some((info, self.travel));
}
let mut changed = self.travel > 0;
self.travel = (self.travel - 1).max(0);
if self.travel <= 0 {
@@ -293,8 +299,11 @@ impl Data {
Tag::compound(entries)
}
pub fn from_nbt(tag: &Tag) -> Self {
let current = tag.get("event").and_then(info_from_nbt);
let reload_particle = current.is_some();
let mut data = Self {
current: tag.get("event").and_then(info_from_nbt),
current,
reload_particle,
..Self::default()
};
if let Some(Tag::Int(t)) = tag.get("event_delay") {
@@ -979,4 +988,30 @@ mod tests {
assert_eq!(back.spreader.cursors[0].charge, 1000);
assert_eq!(back.spreader.cursors[1].charge, 500);
}
#[test]
fn vibration_reloads_particle() {
let tag = Tag::compound([
(
"event",
Tag::compound([
("game_event", Tag::String("minecraft:step".into())),
("distance", Tag::Float(4.0f32.to_bits())),
(
"pos",
Tag::List(vec![
Tag::Double(1.0f64.to_bits()),
Tag::Double(0),
Tag::Double(0),
]),
),
]),
),
("event_delay", Tag::Int(3)),
]);
let mut d = Data::from_nbt(&tag);
assert!(d.reload_particle && d.current.is_some());
let tick = d.tick(10, true, true, |dist| dist as i32);
assert!(tick.resumed.is_some() && !d.reload_particle);
}
}
@@ -203,6 +203,9 @@ pub struct Settings {
pub startup_api: crate::graphics::GraphicsApi,
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
pub video_modes: Vec<crate::window::VideoMode>,
/// Fullbright is held off: this window plays on another's server (set by the game, not
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
pub fullbright_held: bool,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
pub controller: crate::gamepad::PadSettings,
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
@@ -293,6 +296,7 @@ impl Default for Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -337,6 +341,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -507,6 +518,7 @@ impl Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -862,6 +874,10 @@ pub enum Opt {
PadNintendoButtons,
PadKeyboard,
PadGameplayGuide,
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
PadRadialMode,
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
PadRadialStick,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
use super::*;
use crate::options_ui::Event;
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
const SHORT: (f32, f32) = (480.0, 240.0);
const CHANGED: Output = Output::Event(Event::Changed);
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
/// its own, and a row a wheel steps.
#[derive(Default)]
struct Probe {
slider: f32,
choice: usize,
pressed: Vec<(u32, bool)>,
/// How many times its rows were built.
built: std::cell::Cell<usize>,
}
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
const SLIDER: u32 = 0;
const CHOICE: u32 = 1;
const FIRST_FILLER: u32 = 10;
const FILLERS: u32 = 40;
const GREY: u32 = 100;
const WHY: &str = "Not here.";
impl Screen for Probe {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Probe"
}
/// Its rows read the field of view, besides its own state.
fn stamp(&self, s: &Settings) -> u64 {
stamp_of(&s.fov)
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
self.built.set(self.built.get() + 1);
let (value, chosen) = CHOICES[self.choice];
let mut rows = vec![
Row::new(
SLIDER,
"Level".to_owned(),
Value::Slider {
position: self.slider,
steps: Some(10),
text: s.fov.to_string(),
},
),
Row {
chosen: Some(chosen.to_owned()),
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
.about("Picks a colour.".to_owned())
},
Row::new(GREY, "Greyed".to_owned(), Value::Press)
.about("Does a thing.".to_owned())
.blocked(Some(WHY.to_owned())),
];
rows.extend((0..FILLERS).map(|i| {
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
.about("Does nothing.".to_owned())
}));
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
vec![
Sheet::new(
0,
"One".to_owned(),
"The first.".to_owned(),
vec![Group { name: "G", rows }],
),
Sheet::new(
1,
"Two".to_owned(),
"The second.".to_owned(),
vec![Group {
name: "H",
rows: elsewhere,
}],
),
]
}
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
self.pressed.push((row, back));
match row {
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
FIRST_FILLER => s.fov += 1.0,
_ => {}
}
vec![CHANGED]
}
fn undoes(&self) -> bool {
true
}
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.fov = before.fov;
Vec::new()
}
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
self.slider = position;
}
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
(row == CHOICE).then(|| {
self.choice = (self.choice + 1) % CHOICES.len();
vec![CHANGED]
})
}
}
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
shell.point_at(&Settings::default(), size, row)
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
/// away from any row, at the size it was drawn at.
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
let mut batch = HudBatch::new([1708, 960]);
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
let size = (batch.width, batch.height);
shell.scroll(s, size, -1.0);
shell.key(s, K::ArrowDown, None, false, size);
shell.wheel(s, size, (0.0, 0.0), 1.0);
size
}
#[test]
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
let atlas = atlas();
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let built = |shell: &Shell<Probe>| shell.screen.built.get();
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
let view = shell.view(s, size);
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
labels.any(|label| label.ends_with(end))
};
// Each of the four built them before: now the first frame builds them once, the next none.
let size = frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
// A press changes the screen's own state: its row reads the new value.
let at = middle_of(&mut shell, CHOICE, size);
shell.click(&mut s, at, size, false, false, false);
assert!(reads(&mut shell, &s, size, "Blue"));
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
s.fov = 90.0;
frame(&mut shell, &mut s, &atlas);
assert!(reads(&mut shell, &s, size, "90"));
let before = built(&shell);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), before);
// Searching, and another size, build them again.
shell.type_text("filler");
shell.view(&s, size);
assert_eq!(built(&shell), before + 1);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 2);
// So does another language, and coming back from it.
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
assert_eq!(built(&shell), before + 3);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 4);
}
#[test]
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let cols = Columns::of(SHORT.0, SHORT.1);
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
let cut = FIRST_FILLER + 20;
let view = shell.view(&s, SHORT);
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
let rect = cols.row(y - shell.scroll.amount);
assert!(
rect[1] + rect[3] / 2.0 >= cols.bottom,
"the row's middle is hidden"
);
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
// All in view, it stays put.
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
assert_eq!(
shell.click(&mut s, middle, SHORT, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
// Nothing changed: there is no Undo to press.
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(s.fov, 71.0);
// Another option changed meanwhile isn't the screen's to put back.
s.chat_opacity = 0.25;
assert_eq!(
shell.click(&mut s, on_undo, SIZE, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
shell.click(&mut s, at, SIZE, false, false, false);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focus == Some(Target::Undo)
});
assert!(reached);
assert_eq!(
shell.key(&mut s, K::Enter, None, false, SIZE),
vec![CLICK, CHANGED]
);
assert_eq!(s.fov, 70.0);
assert_eq!(shell.focus, Some(Target::Done));
}
#[test]
fn the_strip_says_why_a_greyed_row_cant_be_used() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
let size = (batch.width, batch.height);
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
shell.draw(&s, batch, &atlas, at, false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
let dock = Columns::of(size.0, size.1).dock;
let labelled = crate::widgets::last_labelled();
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
found.map(|w| w.label.clone())
};
let grey = middle_of(&mut shell, GREY, size);
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
let choice = middle_of(&mut shell, CHOICE, size);
assert_eq!(
strip(&mut batch, &mut shell, choice).as_deref(),
Some(CHOICES[0].1)
);
}
#[test]
fn growing_the_window_keeps_the_scroll_within_the_list() {
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
shell.scroll(&s, SIZE, -1000.0);
assert!(shell.scroll.amount > 0.0);
let tall = (SIZE.0, 4000.0);
let view = shell.view(&s, tall);
assert_eq!(shell.scroll.amount, 0.0);
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
assert!(first >= view.cols.top);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let rate = SCROLL_RATE;
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
assert_eq!(shell.scroll.amount, rate);
shell.scroll(&s, SIZE, -2.0);
assert_eq!(shell.scroll.amount, 3.0 * rate);
shell.scroll(&s, SIZE, 100.0);
assert_eq!(shell.scroll.amount, 0.0);
}
#[test]
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(shell.screen.choice, 0);
assert_eq!(shell.scroll.amount, SCROLL_RATE);
s.extras.wheel_cycles_settings = true;
shell.scroll.amount = 0.0;
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(outputs, vec![CHANGED]);
assert_eq!(shell.screen.choice, 1);
assert_eq!(shell.scroll.amount, 0.0);
// A row the screen doesn't step scrolls, the greyed one too.
let over_grey = middle_of(&mut shell, GREY, SIZE);
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
assert_eq!(shell.scroll.amount, SCROLL_RATE);
}
#[test]
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let about = |shell: &mut Shell<Probe>, s: &Settings| {
let view = shell.view(s, SIZE);
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
row.described().to_owned()
};
assert_eq!(about(&mut shell, &s), "Paints it red.");
let at = middle_of(&mut shell, CHOICE, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(about(&mut shell, &s), "Paints it blue.");
// The search reads the row's own description, on every page.
shell.type_text("colour");
let view = shell.view(&s, SIZE);
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
assert!(found.contains(&CHOICE) && !found.contains(&99));
shell.search.set_value("elsewhere");
shell.focus = None;
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
#[test]
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, SLIDER, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(shell.screen.slider, 0.0);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
let mid = (track + width / 2.0, at.1);
assert_eq!(
shell.click(&mut s, mid, SIZE, false, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 0.5);
let end = (track + width, at.1);
assert_eq!(
shell.click(&mut s, end, SIZE, true, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 1.0);
assert!(shell.release().is_some());
assert!(shell.release().is_none());
}
#[test]
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
// Tab: the search, the two pages, then the first row.
for _ in 0..4 {
key(&mut shell, K::Tab);
}
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
// A slider Tab reaches changes with the arrows, in its steps.
key(&mut shell, K::ArrowRight);
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
// The arrows go on past the greyed row and rows out of view, which scroll in.
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
for _ in 0..30 {
key(&mut shell, K::ArrowDown);
}
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
assert!(shell.scroll.amount > 0.0);
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let view = shell.view(&s, SIZE);
let [x, y, w, h] = view.cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert!(shell.typing());
Hosted::blur_search(&mut shell);
assert!(!shell.typing());
// Another focus isn't dropped.
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
Hosted::blur_search(&mut shell);
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_key_that_moves_nothing_shows_no_focus() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
}
#[test]
fn a_cut_description_says_so() {
assert_eq!(cut_off("off, it never "), "off, it never…");
}
#[test]
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
// The next key goes on from the row clicked, and shows where it went.
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
let (wide, base) = (
Columns::of(480.0, 270.0).with_note_lines(4),
Columns::of(480.0, 270.0),
);
assert_eq!(wide.note_lines, 4);
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
assert_eq!(wide.button(0), base.button(0));
// A 720p window at scale 4 has room for one more line, not two.
let short = Columns::of(320.0, 180.0).with_note_lines(4);
assert_eq!(short.note_lines, 3);
assert!(short.bottom - short.top >= MIN_LIST_H);
}
#[test]
fn shift_presses_a_row_backwards() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, true);
// The right button is not a press.
shell.click(&mut s, at, SIZE, false, true, false);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
}
#[test]
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, GREY, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK, Output::Done]
);
}
#[test]
fn a_page_picked_shows_alone() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK]
);
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
/// A screen with more pages than a short view's page list has room for: a page of its own,
/// then a section of `PAGES` under a line, one more page out of the list that the section's
/// first page links to, and a change that waits for Apply.
#[derive(Default)]
struct Pages {
pending: bool,
applied: usize,
}
const PAGES: u8 = 12;
const HOME: u8 = 0;
const DEEP: u8 = 100;
const LINK: u32 = 1000;
const WAITS: u32 = 1001;
const SECTION: &str = "Section";
impl Screen for Pages {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Pages"
}
fn stamp(&self, _: &Settings) -> u64 {
stamp_of(&self.pending)
}
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
let page = |id: u8, name: String, rows| {
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
};
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
for i in 1..=PAGES {
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
if i == 1 {
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
}
let mut sheet = page(i, format!("Page {i}"), rows);
sheet.heading = (i == 1).then(|| SECTION.to_owned());
sheets.push(sheet);
}
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
deeper.listed = false;
sheets.push(deeper);
sheets
}
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
self.pending |= row == WAITS;
vec![CHANGED]
}
fn link(&self, row: u32) -> Option<u8> {
(row == LINK).then_some(DEEP)
}
fn undoes(&self) -> bool {
true
}
fn pending(&self, _: &Settings) -> bool {
self.pending
}
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
self.pending = false;
self.applied += 1;
vec![CHANGED]
}
}
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
/// the page list has room for six places).
const TALL: [u32; 2] = [854, 960];
const SHORT_WINDOW: [u32; 2] = [854, 480];
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
let mut batch = HudBatch::new(window);
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
(batch.width, batch.height)
}
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Tall enough: each page under the last, the deeper page left out as it is in no list.
drawn(&mut shell, &s, TALL);
let tall = shell.page_places();
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
assert!(tall.iter().all(|p| p.2 == Part::Tab));
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
assert_eq!(shell.strip_ends(), None);
// Short: the first page, then the section in one row, its first page between nothing and
// its next. Every place is inside the column, the row's on one line under the section's.
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let short = shell.page_places();
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
assert_eq!(
parts,
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
);
assert_eq!(shell.strip_ends(), Some((false, true)));
let cols = Columns::of(size.0, size.1);
for (_, [x, _, w, _], _) in &short {
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
}
assert_eq!(short[1].1[1], short[2].1[1]);
assert_eq!(short[1].1[1], cols.page(2)[1]);
// The line naming the section stays over it.
let labelled = crate::widgets::last_labelled();
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
}
#[test]
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(1);
let mut seen = vec![1];
let size = loop {
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let places = shell.page_places();
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
break size;
};
assert_eq!(
shell.click(&mut s, middle(rect), size, false, false, false),
[CLICK]
);
assert_eq!(shell.open_page(), Some(next));
seen.push(next);
};
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
assert_eq!(shell.strip_ends(), Some((true, false)));
// At the last page, the right of the row picks nothing: its arrow is dimmed.
let cols = Columns::of(size.0, size.1);
let [x, y, w, h] = cols.page(2);
assert!(
shell
.click(
&mut s,
(x + w - 2.0, y + h / 2.0),
size,
false,
false,
false
)
.is_empty()
);
assert_eq!(shell.open_page(), Some(PAGES));
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
// it to the middle of the row.
let before = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(before, Part::Prev);
shell.set_focus(Some(Target::Page(PAGES - 1)));
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
drawn(&mut shell, &s, SHORT_WINDOW);
let now = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(now, Part::Shown);
}
#[test]
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
shell.set_search("row 1");
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
assert_eq!(pages(&shell), [HOME, 1, 10]);
assert_eq!(shell.strip_ends(), Some((false, true)));
// Its arrow goes to the next match, which has the first one before it and the last after.
let next = shell.page_places()[2].1;
shell.click(&mut s, middle(next), size, false, false, false);
drawn(&mut shell, &s, SHORT_WINDOW);
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
assert_eq!(shell.strip_ends(), Some((true, true)));
}
#[test]
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(5);
drawn(&mut shell, &s, SHORT_WINDOW);
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
.into_iter()
.filter_map(|t| match t.kind {
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
_ => None,
})
.collect();
// The first page, the page before, the open one (lit) and the one after.
assert_eq!(tabs.len(), 4, "{tabs:?}");
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
assert!(open.1 && before < open.0 && open.0 < after);
// The arrows are vanilla's Previous Page and Next Page to the narrator.
let labels = crate::widgets::last_labelled();
let named = |key| {
labels
.iter()
.any(|w| w.label == lang::get(key) && w.enabled)
};
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
}
#[test]
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Found by the search from another page: back is the page the link is on.
shell.show(HOME);
shell.set_search("deeper");
let at = shell.point_at(&s, SIZE, LINK);
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
// The page opens alone, the search cleared; the list lights the page the link was on.
assert_eq!(shell.open_page(), Some(DEEP));
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
drawn(&mut shell, &s, TALL);
assert_eq!(shell.lit_page(), Some(1));
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
assert_eq!(shell.open_page(), Some(1));
assert_eq!(
shell.key(&mut s, K::Escape, None, false, SIZE),
[Output::Done]
);
}
#[test]
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
let done = Columns::of(SIZE.0, SIZE.1).button(0);
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
let at = shell.point_at(&s, SIZE, WAITS);
shell.click(&mut s, at, SIZE, false, false, false);
let foot = shell.foot_at(&s, SIZE);
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
// Side by side over Done, as wide as it together.
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
assert_eq!(
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
[CLICK, CHANGED]
);
assert_eq!(shell.screen.applied, 1);
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Done]);
}
#[test]
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let choice = middle_of(&mut shell, CHOICE, SIZE);
assert!(
shell
.click(&mut s, choice, SIZE, false, true, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
}
+332 -7
View File
@@ -932,7 +932,56 @@ pub struct Downloader {
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
/// How long a failed texture download waits before it is tried again.
retry_after: std::time::Duration,
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
state_order: std::collections::VecDeque<(Kind, String)>,
/// The `resources` keys, oldest first ([`Bounds::resources`]).
resource_order: std::collections::VecDeque<String>,
/// Downloads started whose answer `state` has not taken yet: one thread each.
in_flight: usize,
/// Fetches a texture's body; the HTTP GET, but for a test.
fetch: fn(&str) -> Option<Vec<u8>>,
bounds: Bounds,
}
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
struct Answer {
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
key: Option<(Kind, String)>,
}
impl Answer {
fn give(&mut self, image: Option<Vec<u8>>) {
if let Some(key) = self.key.take() {
let _ = self.send.send((key, image));
}
}
}
impl Drop for Answer {
fn drop(&mut self) {
self.give(None);
}
}
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
/// `texture` id a `resources` entry, none of them ever dropped.
#[derive(Clone, Copy, Debug)]
struct Bounds {
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
/// dropped texture is loaded again from the disk cache when it is asked for.
states: usize,
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
resources: usize,
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
/// caller, which asks again every frame, starts it when one has finished.
downloads: usize,
}
const BOUNDS: Bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 16,
};
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
const MAX_SKIN_IMAGES: usize = 1024;
@@ -964,6 +1013,11 @@ impl Downloader {
resources: Default::default(),
failed: Default::default(),
retry_after: std::time::Duration::from_secs(60),
state_order: Default::default(),
resource_order: Default::default(),
in_flight: 0,
fetch: http_get,
bounds: BOUNDS,
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
@@ -973,6 +1027,7 @@ impl Downloader {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_order.clear();
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
@@ -1002,12 +1057,47 @@ impl Downloader {
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
if self.resources.insert(location.to_owned(), state).is_none() {
self.resource_order.push_back(location.to_owned());
while self.resources.len() > self.bounds.resources
&& let Some(old) = self.resource_order.pop_front()
{
self.resources.remove(&old);
}
}
}
/// Forgets how `location` loaded, so `resource` reads it again.
fn forget_resource(&mut self, location: &str) {
if self.resources.remove(location).is_some() {
self.resource_order.retain(|l| l != location);
}
}
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
/// downloads running are never dropped, so their answer has a key to land on.
fn set_state(&mut self, key: (Kind, String), state: Download) {
if self.states.insert(key.clone(), state).is_some() {
return;
}
self.state_order.push_back(key);
while self.states.len() > self.bounds.states {
let Some(at) = self
.state_order
.iter()
.position(|k| self.states.get(k) != Some(&Download::Pending))
else {
break;
};
if let Some(old) = self.state_order.remove(at) {
self.states.remove(&old);
self.failed.remove(&old);
}
}
}
/// How the download of `url` stands, starting it the first time. A
/// failure is permanent for `retry_after`, then tried again.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
self.in_flight = self.in_flight.saturating_sub(1);
let state = match image {
Some(rgba) => {
match key.0 {
@@ -1023,29 +1113,42 @@ impl Downloader {
Download::Failed
}
};
self.states.insert(key, state);
self.set_state(key, state);
}
let Some(hash) = texture_hash(url) else {
return Download::Failed;
};
let key = (kind, hash);
let full = self.in_flight >= self.bounds.downloads;
if let Some(state) = self.states.get(&key).copied() {
let due = self
.failed
.get(&key)
.is_none_or(|at| at.elapsed() >= self.retry_after);
if state != Download::Failed || !due {
// A failure that is due waits for a free download like a new texture does.
if state != Download::Failed || !due || full {
return state;
}
self.states.remove(&key);
self.failed.remove(&key);
self.state_order.retain(|k| *k != key);
}
self.states.insert(key.clone(), Download::Pending);
if full {
// Not recorded: the next call asks again, and starts it once one has finished.
return Download::Pending;
}
self.in_flight += 1;
self.set_state(key.clone(), Download::Pending);
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
let fetch = self.fetch;
std::thread::spawn(move || {
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
let _ = send.send((key, image));
let mut answer = Answer {
send,
key: Some(key),
};
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
answer.give(image);
});
Download::Pending
}
@@ -1108,7 +1211,7 @@ impl Downloader {
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
self.forget_resource(location);
return false;
};
self.queue(location.to_owned(), image);
@@ -1925,3 +2028,225 @@ mod tests {
assert_eq!(served.load(Ordering::SeqCst), 2);
}
}
#[cfg(test)]
mod download_bound_tests {
use super::{Bounds, Download, Downloader, Kind};
use std::sync::atomic::{AtomicUsize, Ordering};
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
let root =
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut d = Downloader::new(root);
d.fetch = fetch;
d.bounds = bounds;
d
}
fn url(i: usize) -> String {
format!("https://textures.example/texture/skin{i:05}")
}
/// Asks again until the texture's download has settled.
fn wait(d: &mut Downloader, url: &str) -> Download {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = d.state(Kind::Skin, url);
if state != Download::Pending || std::time::Instant::now() > deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
static RUNNING: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
fn slow_failure(_: &str) -> Option<Vec<u8>> {
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
PEAK.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
RUNNING.fetch_sub(1, Ordering::SeqCst);
None
}
/// The same, not counted: tests run side by side, and the count is another test's.
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(20));
None
}
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
#[test]
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 4,
};
let mut d = downloader("threads", bounds, slow_failure);
let urls: Vec<String> = (0..60).map(url).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut first = true;
loop {
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
if first {
// Asked all at once: four started, and the others were not even recorded.
assert_eq!(d.in_flight, 4);
assert_eq!(d.states.len(), 4);
assert!(states.iter().all(|s| *s == Download::Pending));
first = false;
}
if states.iter().all(|s| *s == Download::Failed) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"{} still pending",
states.len()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(
PEAK.load(Ordering::SeqCst) <= 4,
"{}",
PEAK.load(Ordering::SeqCst)
);
assert_eq!(d.states.len(), 60);
assert_eq!(d.in_flight, 0);
}
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
/// (and their failure time with them), and one that went is asked again as a new texture.
#[test]
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
let bounds = Bounds {
states: 8,
resources: 4096,
downloads: 16,
};
let mut d = downloader("states", bounds, |_| None);
for i in 0..40 {
let u = url(i);
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
assert!(d.failed.len() <= 8);
}
let kept: Vec<usize> = (0..40)
.filter(|i| {
d.states
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
})
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// The first one went: asked again, it starts again (and fails again).
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A fetch that panics, as a decoder can on a body a server chose.
fn panicking(_: &str) -> Option<Vec<u8>> {
panic!("a fetch that panics, on purpose");
}
/// A thread that panics before it answers still gives its place back: with two places and six
/// textures whose fetch panics, every one settles as failed instead of the places staying
/// taken for good.
#[test]
fn a_download_that_panics_gives_its_place_back() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 2,
};
let mut d = downloader("panics", bounds, panicking);
for i in 0..6 {
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
}
assert_eq!(d.in_flight, 0);
assert_eq!(d.states.len(), 6);
}
/// A fetch long enough for a test to ask between its start and its end.
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(150));
None
}
/// A failure whose wait is over is retried like a new texture: not while every place is taken
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
#[test]
fn a_failure_that_is_due_waits_for_a_free_place() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 1,
};
let mut d = downloader("due", bounds, slowish_failure);
let wait_time = d.retry_after;
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
// Another texture takes the only place; the first one's retry becomes due but has to wait.
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
assert_eq!(d.in_flight, 1);
d.retry_after = wait_time;
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
// The place is free: the retry starts.
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A texture that is running is never the one dropped, however many settle behind it.
#[test]
fn a_running_download_keeps_its_place_in_the_states() {
let bounds = Bounds {
states: 2,
resources: 4096,
downloads: 16,
};
let mut d = downloader("running", bounds, sleepy_failure);
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
for i in 1..6 {
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
}
assert_eq!(
d.states
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
Some(&Download::Pending)
);
assert!(d.states.len() <= 3);
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
}
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
/// longer pile up, and one that was dropped is read from the packs again.
#[test]
fn the_resource_textures_keep_to_their_bound() {
let bounds = Bounds {
states: 4096,
resources: 8,
downloads: 16,
};
let mut d = downloader("resources", bounds, |_| None);
for i in 0..40 {
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
}
let kept: Vec<usize> = (0..40)
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// Forgetting one by hand (as `again` does) leaves no stale key behind.
d.forget_resource("patch/n/35");
assert_eq!(d.resource_order.len(), d.resources.len());
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
// New packs start the count over.
d.set_resource_root(std::env::temp_dir());
assert!(d.resources.is_empty() && d.resource_order.is_empty());
}
}
@@ -913,6 +913,20 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Update volume, pitch, and position in a single backend call.
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
self.set_volume(channel, volume);
self.set_pitch(channel, pitch);
self.set_position(channel, position);
}
/// Filter which handles have stopped playing in a single pass.
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
for &(h, c) in live {
if self.stopped(c) {
stopped.push(h);
}
}
}
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
@@ -1494,13 +1508,14 @@ impl SoundEngine {
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
let mut stopped = std::mem::take(&mut self.mapped_scratch);
stopped.clear();
backend.filter_stopped(&live, &mut stopped);
for h in stopped.drain(..) {
self.release_handle(h, backend);
}
// Released handles nobody maps any more.
let mut mapped = std::mem::take(&mut self.mapped_scratch);
let mut mapped = stopped;
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
@@ -1550,9 +1565,7 @@ impl SoundEngine {
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);
backend.set_position(c, position);
backend.update_channel(c, volume, pitch, position);
}
let instance = self.instances.get_mut(&id).expect("present");
if instance.looping
+98 -19
View File
@@ -127,6 +127,9 @@ pub struct Sprites {
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<StillKey, u32>,
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
/// layer of its own ([`Sprites::keep_apart`]).
apart: std::collections::HashSet<String>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -218,6 +221,13 @@ impl Sprites {
..Self::default()
}
}
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
/// with the same pixels). Before the textures that bear these names are inserted.
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
self.apart.extend(names);
}
/// Write what this run decoded to the cache file.
pub fn save_disk_cache(&mut self) {
if let Some(disk) = &mut self.disk {
@@ -287,8 +297,13 @@ impl Sprites {
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
let frames: Arc<Vec<Vec<u8>>> =
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
// Native frames already match the atlas size: share the immutable
// buffers with animations rather than copying every frame again.
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
Arc::clone(frames)
} else {
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
};
self.pixels.extend_from_slice(&frames[*initial]);
resized.push(frames);
}
@@ -686,23 +701,24 @@ impl Sprites {
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
let still =
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
return Ok(same);
@@ -872,6 +888,35 @@ mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn native_frames_at_atlas_size_are_shared_without_resampling() {
let pixels = vec![
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
];
let native = Arc::new(pixels.clone());
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
let metadata = r#"{"frametime":2}"#;
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::clone(&native), 1),
(8, Arc::clone(&smaller), 0),
],
pbr: vec![[None, None], [None, None]],
animations: vec![
test_frames(0, pixels.clone(), metadata),
test_frames(1, smaller.as_ref().clone(), metadata),
],
..Default::default()
};
sprites.finish();
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
}
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
@@ -1282,6 +1327,40 @@ mod tests {
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
}
#[test]
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
// A resource pack's transparent placeholders: a model's layer and a tile another
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
// rule finds its faces by layer, so the one it names can't share the others'.
let root =
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
let directory = root.join("assets/a/textures/block");
fs::create_dir_all(&directory).unwrap();
let write = |name: &str| {
let mut encoder =
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[0; 16 * 16 * 4])
.unwrap();
};
for name in ["placeholder.png", "named.png", "again.png"] {
write(name);
}
let mut sprites = Sprites::default();
sprites.keep_apart(["a:block/named".to_owned()]);
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
let named = sprites.insert(&root, "a:block/named").unwrap();
let again = sprites.insert(&root, "a:block/again").unwrap();
fs::remove_dir_all(&root).unwrap();
// The named one alone; the others still share.
assert_ne!(named, placeholder);
assert_eq!(again, placeholder);
assert_eq!(sprites.count(), 2);
}
#[test]
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
let mut sprites = Sprites {
native: vec![
+26 -22
View File
@@ -605,6 +605,9 @@ pub struct Streaming {
/// Set while `update` runs, after it refreshed `cover`: nothing else can
/// change `extra` meanwhile, so `cover` answers without comparing them.
cover_current: bool,
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
/// they change; read like `cover`, while `cover_current`.
forced_cover: Option<ForcedCover>,
/// Mesh the sections for drawing (false on a server thread).
pub mesh: bool,
/// The pregeneration band (`set_band`).
@@ -806,6 +809,7 @@ impl GenerationScan {
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
forced_cover: None,
band: input.ranges.band,
};
self.remember(ranges, input.world);
@@ -1066,8 +1070,10 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod forced_cover;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1176,6 +1182,7 @@ impl Streaming {
extra: Vec::new(),
cover: None,
cover_current: false,
forced_cover: None,
mesh: true,
band: None,
band_ranked: None,
@@ -1691,6 +1698,7 @@ impl Streaming {
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
@@ -1870,6 +1878,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
let (protos, generating) = (&self.protos, &self.generating);
@@ -2040,6 +2049,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
self.waiting.retain(|&(x, sy, z)| {
@@ -2145,6 +2155,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
},
world,
@@ -2283,6 +2294,7 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
}
}
@@ -2563,6 +2575,8 @@ struct Ranges<'a> {
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
forced_cover: Option<&'a ForcedCover>,
band: Option<&'a Band>,
}
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
@@ -2611,6 +2625,7 @@ impl Band {
+ i64::from(x - self.x0 + 8).max(0)
}
}
/// For every column near the extra areas, how far it lies outside the
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
/// loaded and wanted column whether one reaches it, several times a tick:
@@ -2918,16 +2933,6 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
@@ -2939,18 +2944,7 @@ impl Ranges<'_> {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
self.near_forced(x, z, margin)
}
}
impl Drop for Streaming {
@@ -3484,6 +3478,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let generate = |x, z, priority, order| Queued {
@@ -3724,6 +3719,7 @@ mod tests {
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
},
world: &self.world,
@@ -4107,6 +4103,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let square = |x: i32, z: i32, n: i32| {
@@ -4164,6 +4161,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let span = radius * 3 + 20;
@@ -4201,6 +4199,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
for x in -5i32..=15 {
@@ -4235,6 +4234,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
@@ -4290,6 +4290,7 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
@@ -4430,6 +4431,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
@@ -4473,6 +4475,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
@@ -4539,6 +4542,7 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
// The columns streaming asks about: everything kept, and the
@@ -0,0 +1,165 @@
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
//! distance map.
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
use crate::fast_hash::FxHashMap;
use std::collections::HashSet;
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
/// through every forced chunk (it is asked for every column, several times a tick).
pub(super) struct ForcedCover {
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
near: FxHashMap<(i32, i32), u8>,
}
impl ForcedCover {
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
const REACH: i32 = FORCED_TERRAIN_RING + 1;
/// Past this many forced chunks the cover is not made: the scan answers.
const MOST: usize = 4096;
pub(super) fn refresh(
cover: &mut Option<Self>,
forced: &HashSet<(i32, i32)>,
mod_forced: &HashSet<(i32, i32)>,
) {
if cover
.as_ref()
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
{
return;
}
if forced.len() + mod_forced.len() > Self::MOST {
*cover = None;
return;
}
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
}
}
*cover = Some(Self {
forced: forced.clone(),
mod_forced: mod_forced.clone(),
near,
});
}
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
(reach <= Self::REACH).then(|| {
self.near
.get(&(x, z))
.is_some_and(|&d| i32::from(d) <= reach)
})
}
}
impl Ranges<'_> {
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
/// `margin` asks for.
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
return hit;
}
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
let mut next = |m: i32| {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
(*seed % (2 * m as u64 + 1)) as i32 - m
};
(0..n).map(|_| (next(spread), next(spread))).collect()
}
/// With the forced chunks by distance, every column gets the answer the scan of every forced
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
/// forced chunks change too.
#[test]
fn the_distances_answer_as_the_scan() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut cover = None;
for case in 0..24 {
let spread = [3, 12, 40][case % 3];
let forced = chunks(&mut seed, 1 + case % 9, spread);
// Some mod tickets on forced chunks, some apart.
let mut mod_forced = chunks(&mut seed, case % 4, spread);
mod_forced.extend(forced.iter().take(case % 2));
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
let made = cover.as_ref().expect("a few forced chunks get a map");
let ranges = |forced_cover| Ranges {
// The player's circle far away: only the forced chunks answer.
center: (10_000, 10_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &mod_forced,
cover: None,
forced_cover,
band: None,
};
let (scan, map) = (ranges(None), ranges(Some(made)));
let span = spread + 10;
for z in -span..=span {
for x in -span..=span {
for margin in 0..=7 {
assert_eq!(
map.in_main_range(x, z, margin),
scan.in_main_range(x, z, margin),
"case {case} ({x}, {z}) margin {margin}"
);
}
for d in [false, true] {
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
}
}
}
}
}
/// Too many forced chunks for a map: none is made, and the scan answers.
#[test]
fn past_the_most_no_map_is_made() {
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
let mut cover = None;
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
assert!(cover.is_some());
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
assert!(cover.is_none());
}
}
@@ -304,19 +304,19 @@ impl StudioUi {
// Card background & highlight
if is_selected {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, rect, style::open());
} else if is_hovered {
style::fill(batch, atlas, rect, style::HOVER);
style::fill(batch, atlas, rect, style::hover());
} else {
style::well(batch, atlas, rect);
}
let border_color = if is_selected {
style::ACCENT
style::accent()
} else if is_hovered {
style::WHITE
} else {
style::ROW_RULE
style::row_rule()
};
style::ring(batch, atlas, rect, border_color);
+18 -8
View File
@@ -524,8 +524,18 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
var code = 0u;
if corner == 1u {
code = (hi.x >> 28u) & 7u;
} else if corner == 2u {
code = (hi.y >> 24u) & 7u;
} else if corner == 3u {
code = (hi.y >> 27u) & 7u;
}
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
| ((g * (t & 255u) / 255u) << 16u) | alpha;
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
@@ -955,13 +965,13 @@ const PI: f32 = 3.14159265;
fn disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
if d.y * y <= 0.0 { return vec2(-1.0); }
let p = d * (y / d.y);
let h = length(p.xz);
let phi = atan2(p.z, p.x);
let sector = PI / 4.0;
let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
let edge = 512.0 * cos(sector * 0.5) / cos(delta);
if h > edge { return vec2(-1.0); }
let s = h / edge;
// The regular octagon's edge parameter, directly from its half planes.
// max(|x| + tan(pi/8)*|z|, |z| + tan(pi/8)*|x|) / radius
// equals length(p.xz) / edge without atan2, floor or cos per pixel.
let a = abs(p.xz);
let diagonal = 0.41421356237309503;
let s = max(a.x + diagonal * a.y, a.y + diagonal * a.x) / 512.0;
if s > 1.0 { return vec2(-1.0); }
let ay = abs(y);
let rim = sqrt(512.0 * 512.0 + y * y);
return vec2(ay + s * (rim - ay), ay + s * (512.0 - ay));
+23 -1
View File
@@ -1251,6 +1251,9 @@ pub struct EditBox {
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
pub invert_highlighted_text_color: bool,
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
/// round the frame, not over the text.
pub frame: Option<[f32; 4]>,
@@ -1543,6 +1546,7 @@ impl EditBox {
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
invert_highlighted_text_color: true,
frame: None,
}
}
@@ -1918,7 +1922,11 @@ impl EditBox {
let right = self.x + self.width;
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
let (a, b) = (x0.min(x1), x0.max(x1));
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
batch.text_highlight(
atlas,
(a, ty - 1.0, b - a, 11.0),
self.invert_highlighted_text_color,
);
}
if show_cursor {
if insert {
@@ -2628,6 +2636,20 @@ mod tests {
);
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
assert!(edit.invert_highlighted_text_color);
edit.invert_highlighted_text_color = false;
edit.set_value("name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert_eq!(edit.selected_text(), "name");
assert!(!edit.invert_highlighted_text_color);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
@@ -14,6 +14,8 @@ pub enum Cursor {
Arrow,
PointingHand,
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
}
thread_local! {
Loaded 100 of 853 files, more files were not shown because too many files have changed in this diff. Show more