Compare commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:25:11 +00:00
DUzzL a0677225dd Phantom: empty look control keeps the pitch its move control sets 2026-11-10 18:31:53 +01:00
MSedgeMC 7950bfbc6f port map: the lectern's Take Book row names the build gate it already has 2026-11-10 17:29:49 +00:00
DetimesandClaude Opus 5.5 c9cd62bd5b Keep game.rs from growing: the tick-view gate lives with the criteria helpers
game.rs is over the 3000-line limit, so the gate and its test move into
native_criteria.rs, which already builds the player's advancement views,
instead of a new module declared in game.rs.

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

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

Fixes #14880

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

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

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

Part of #12989.

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

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

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

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

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

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

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

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

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

Closes #14503

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

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

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

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

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

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

Fixes #14544

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:00:38 +01:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
valuttaandClaude Fable 5.2 0a079cdd75 Server tick: boats and minecarts read the mobs near them, not every mob
Each vehicle walked every mob of the level several times a tick:

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

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

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-09 21:40:42 +01:00
YourPancakes fd70c6aa11 perf(render): reuse native sprite frames when sizes match 2026-11-09 23:36:19 +03:00
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
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
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
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
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
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
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
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
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
Horizon agentandClaude Opus 5.5 7311870047 Pages: inactive, half and footer buttons, text rows and space (spec #14167)
Generic row options a page drawn as its own screen needs (Play with Friends'
page, #14167 step 2a): Row::button(..).active(false)/.half()/.footer(),
Row::text(..).quiet()/.groups(), Row::Space. The options shell shows text as
notes, pairs half buttons and ignores space; Extras shows active buttons only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:55:32 +01: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
Ludovic Marie 12578352dc Merge pull request #14203 from Evan-lhm/rt/ray-query-opt-in
🤖 Agent: EXPERIMENTAL_RAY_QUERY is requested only with HORIZON_RAY_TRACING=1 in a horizon-extras build; without it the device request is main's.
2026-11-09 20:41:33 +01:00
Ludovic Marie 11ebc3f199 Merge pull request #13960 from horizon-rust/layers/packs-emissive
🤖 Agent: the emissive suffix is read once per pack load through PackFormats::emissive_suffix; atlas building only compares a stored string per sprite as before. layer plan M6 step 3.
2026-11-09 20:41:27 +01: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
Ludovic Marie bc08885276 Merge pull request #14142 from MSedgeMC/fix/shulker-face-bytes
🤖 Agent: AttachFace wraps through from3DDataValue and Peek/Color stay raw bytes; texture() reads the colour signed, so -1 draws white as DyeColor.byId's ZERO strategy does.
2026-11-09 20:37:15 +01:00
Ludovic Marie bc7b8889de Merge pull request #14126 from Joansitoh/fix/9965-warden-save
🤖 Agent: the LAN owner's warden tracker now goes into g.saved before playerdata::store rewrites player_extra from it, so warning level and cooldown survive a save; with_warden keeps lan.rs (over 3000 lines) from growing.
2026-11-09 20:37:11 +01:00
Ludovic Marie 7180a52bf2 Merge pull request #14014 from oloion/fix/elytra-wall-sound-repeat
🤖 Agent: handleFallFlyingCollisions now compares lastSpeed (before updateFallFlyingMovement) with the delta Entity.move leaves, collided axes zeroed, so a glider pressed on a wall stops replaying the crash sound.
2026-11-09 20:37:05 +01: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
Ludovic Marie 8e37c4a4b7 Merge pull request #14215 from DUzzL/fix/14176-lod-mesh-samples
🤖 Agent: bring_back now calls sync_sample for a returning kept tile, so check_mesh_samples sees it.
2026-11-09 20:32:42 +01:00
Ludovic Marie 94ba80f90d Merge pull request #14074 from DUzzL/fix/14034-glass-data-move
🤖 Agent: the glass About card now builds location_row (Move…, confirm, retry/default), and the row keeps the Open button beside Move (agent fix eb3157d; needs the launcher job green: no local launcher build).
2026-11-09 20:32:38 +01:00
Ludovic Marie 238fc99add Merge pull request #14129 from NikitaMGrimm/hdr-windows-calibrate
🤖 Agent: only os_reported (Windows) sets peak_guessed, so Wayland and macOS keep their labels; main merged, the docs paragraph re-applied on main's text, and main's ui_nits test given the field.
2026-11-09 20:32:33 +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
Ludovic Marie ac96d3fa35 Merge pull request #13959 from horizon-rust/layers/packs-colors
🤖 Agent: pack colours (OptiFine color.properties, Polytone colors) are now read through pack_hooks::PackFormats once per pack load; the engine keeps a plain PackColors table, so per-block tint lookups stay table reads. layer plan M6 step 2. Merging main moved its new Polytone damage-bar colours behind PackFormats::item_bars, test moved with them.
2026-11-09 20:26:44 +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
Ludovic Marie ceff25fca1 Merge pull request #14188 from vnsmoreira/packs/polytone-post-depth
🤖 Agent: the world depth is kept whenever post effects run (use_depth_buffer reads it), only in that case; move_off_page moved out unchanged so gfx.rs doesn't grow.
2026-11-09 20:23:18 +01:00
Ludovic Marie 36d0695742 Merge pull request #14169 from MSedgeMC/port_map/trapdoor-and-dismount
🤖 Agent: both rows match main: Mob::on_climbable has the trapdoor-over-ladder rule and Game::mount_events posts entity_dismount.
2026-11-09 20:23:14 +01:00
Ludovic Marie 94484675a8 Merge pull request #14044 from Ragbie/refactor/minecarts-split
🤖 Agent: checked line by line: every removed line reappears in minecarts/nbt.rs or minecarts/index.rs (pure move, exempt from the 500-line count); the new head adds only main merges (same tree as the reviewed head merged with main).
2026-11-09 20:23:10 +01:00
Ludovic Marie 9097d2973d Merge pull request #13921 from MSedgeMC/port_map/bow-release-file
🤖 Agent: confirmed on main: the player's bow is Game::release_bow at game/use_hand.rs:599; game.rs no longer has it.
2026-11-09 20:23:06 +01:00
Ludovic Marie 00e573cf87 Merge pull request #13911 from Skr1zzzz/split-menu-clicks
🤖 Agent: pure move; reset_inventory_clicks and player_menu_sound have no caller outside game/, so pub(super) from menu_clicks.rs reaches them all.
2026-11-09 20:23:02 +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
Ludovic Marie a3bf6531e0 Merge pull request #14019 from MSedgeMC/fix/enchant-update-presence
🤖 Agent: EnchantmentHelper.updateEnchantments returns EMPTY when the component is absent, so enchant is now a no-op on a stack whose enchantments (or a book's stored ones) were removed.
2026-11-09 20:14:49 +01:00
Ludovic Marie 0ddd7ec41d Merge pull request #14012 from TheMrZZ/fix/play-ping-response
🤖 Agent: a play PingRequest is answered with PongResponse on its own connection (handlePingRequest) and kept through the held/handover filter; it doesn't touch last_action_tick. 5/5 ping tests pass with resources.
2026-11-09 20:14:44 +01:00
Ludovic Marie cea1a54e31 Merge pull request #14184 from horizon-rust/agent/telemetry-sqlite-3.54
🤖 Agent: rusqlite 0.41 with fallible_uint keeps the u64/usize conversions the server stores; fmt, clippy -D warnings and its 59 tests pass inside the telemetry workspace (pr_ready skips it).
2026-11-09 20:14:40 +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
Ludovic Marie 23dd21e928 Merge pull request #13999 from Mik00000/perf/spatial-entity-collision
🤖 Agent: SeenGrid cells stay sorted (binary insert on update), so candidates merges 1-4 cells without sort_unstable and still returns snapshot order (the 2000-round randomized test holds); main merged (world.rs), bench seed's hex grouping fixed for clippy.
2026-11-09 20:09:00 +01:00
Ludovic Marie 6cf95bb4d7 Merge pull request #14198 from horizon-rust/docs/mod-prs-to-ari-fork
🤖 Agent: docs only, one CONTRIBUTING paragraph pointing mod-related pull requests to arismoko/horizon (the project decision of 11-09); mods.md and native-mods.md never said where to send them, so they stay.
2026-11-09 20:08:56 +01:00
Ludovic Marie cf09a6610a Merge pull request #14159 from horizon-rust/layers/extras-sections
🤖 Agent: adds horizon_hooks::pages::Place::Extras, a page with no entry of its own whose rows join the Extras page group named by their title (or get a group of their own after the page's groups); stacked on #14153 (layers/pages-pause).
2026-11-09 20:08:51 +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
Ludovic Marie 094bb83dff Merge pull request #14153 from horizon-rust/layers/pages-pause
🤖 Agent: with no page registered for the pause menu, pause_rows is the split row alone (or empty), so the grid, icon row and focus ids are exactly as before; registered pages take ids 40+index and open the options screen at Page::Added like the split row does.
2026-11-09 20:05:51 +01:00
Ludovic Marie dfc91826bd Merge pull request #14164 from horizon-rust/split/telemetry-display
🤖 Agent: horizon-ctl status's display object is built by window::display now, the same width/height/refresh/scale fields as before, so tools reading it see no change.
2026-11-09 20:03:00 +01: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
Ludovic Marie 2782c5e810 Merge pull request #14110 from horizon-rust/layers/pages-slider
🤖 Agent: a registered slider is a real Opt (Opt::Page(page,row)), so drag, arrow keys, narration, focus and search come from the existing slider code; its arms read the page's rows only for that variant, and options_ui.rs still shrinks (horizon_page_tooltip moves out).
2026-11-09 20:00:45 +01:00
Ludovic Marie 86f1749664 Merge pull request #14081 from horizon-rust/layers/hud-layers
🤖 Agent: per-frame cost with no layer registered is one sealed-slice load and is_empty in draw_extras (no canvas, no corner pass); a layer's place() uses native_corner_shown, so F3 hides the same corners for it as for mods' boxes.
2026-11-09 20:00:41 +01:00
Ludovic Marie d149cc1dca Merge pull request #14139 from Joansitoh/fix/14125-nautilus-feed
🤖 Agent: matches TamableAnimal.feed(…, 2.0F, 1.0F): 2 x nutrition, 1 without a food component; reads the item's food, not a patched stack's.
2026-11-09 20:00:32 +01: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
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
Ludovic Marie ae963b0a98 Merge pull request #14057 from MSedgeMC/fix/book-sign-title
🤖 Agent: pushed one fix: the control socket's sign on a read-only book again answers 'not a book and quill' (the catch-all arm had turned it into a silent success).
2026-11-09 19:55:43 +01:00
Ludovic Marie c30911cddf Merge pull request #14023 from MSedgeMC/fix/attribute-modifiers-component
🤖 Agent: pushed one fix: the stack's modifier amount is read as a double (it went through float_value, so 0.1 became 0.10000000149), and the test now pins 0.1.
2026-11-09 19:55:37 +01:00
Ludovic Marie 38eac394be Merge pull request #14016 from horizon-rust/split/revert-render-pipeline-config
🤖 Agent: plain git revert -m 1 of #13968, which put the shaders/ pack-format config into an engine crate (horizon-render-pipeline); the layer rule puts that format in platform, and no layers.json allowed entry changes.
2026-11-09 19:55:32 +01:00
Ludovic Marie 5546650bd7 Merge pull request #14070 from horizon-rust/layers/pages-registry
🤖 Agent: with no page registered the options screen is unchanged (Horizon Settings gets no extra row, nothing is asked per frame); a registered page's rows are rebuilt only while it is open, and every act sends Event::Changed so the crate's settings store is saved.
2026-11-09 19:55:29 +01: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
Ludovic Marie c277636360 Merge pull request #14003 from iseaprojects/fix/12633-recipe-book-dye-imbue
🤖 Agent: crafting_dye and crafting_imbue entries follow DyeRecipe.display and ImbueRecipe.display exactly (OnlyWithComponent dye, DyedSlotDemo; WithAnyPotion source and result), checked against the 26.3 bytecode.
2026-11-09 19:49:57 +01:00
Ludovic Marie 68a01ee7ca Merge pull request #14033 from horizon-rust/layers/moderation-durations
🤖 Agent: pure move of 82 lines that puts the moderation durations into horizon-server-files, the first step of the chat filter's engine hook (#13942 is stacked on it).
2026-11-09 19:49:53 +01:00
Ludovic Marie 15e83578ee Merge pull request #14119 from bvironn/relay/nameplate-unknown-budget
🤖 Agent: only unknown codes spend the nameplate bucket, so once a code exists an attacker's joins stop draining it and a friend waits at most one refill (12 s at 5/min).
2026-11-09 19:49:48 +01:00
Ludovic Marie 66e8c0d04d Merge pull request #14151 from horizon-rust/agent/mob-geometry-move
🤖 Agent: pure move; mob_geometry and mob_geometry_inner go from creatures.rs to creatures/mob_geometry.rs, only super:: paths one level deeper and two rustfmt reflows.
2026-11-09 19:49:44 +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
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
Ludovic Marie 6bab737cae Merge pull request #14075 from Ragbie/refactor/spawn-egg-stack-config-module
🤖 Agent: verified pure move: every removed line reappears in creatures/spawn_eggs.rs once visibility and super::super:: are normalised; creatures.rs 8896 -> 8732 counted lines.
2026-11-09 19:43:40 +01:00
Ludovic Marie 7093c79eeb Merge pull request #14166 from horizon-rust/split/friends-join-official
🤖 Agent: joining a friend now works only in horizon-official (the shipped game); the friends harness was switched to that binary in the same PR, so CI's friends_integration run is the proof that release builds still join.
2026-11-09 19:43:36 +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
Ludovic Marie a4da356144 Merge pull request #14115 from horizon-rust/agent/particles-spawn-move
🤖 Agent: pure move; ParticleEngine::send_particles and create go unchanged from particles.rs to particles/spawn.rs, so particles.rs shrinks by 67 lines.
2026-11-09 19:39:40 +01: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
Ludovic Marie 6c2c706959 Merge pull request #13997 from horizon-rust/split/friends-screen-glue
🤖 Agent: pure move of the Friends screen out of the engine library; the build without Friends keeps naming the pause icon "Play with Friends" from friends_off.rs, so the widget tree and controller focus are unchanged.
2026-11-09 19:33:42 +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
Ludovic Marie 6769422c88 Merge pull request #14101 from horizon-rust/docs/fork-microsoft-client-id
🤖 Agent: docs only: docs/forking.md now says forks register their own Azure app and may not ship or fetch Horizon's client ID; nothing found to change.
2026-11-09 19:30:29 +01:00
Ludovic Marie ebe7b4a4fd Merge pull request #14112 from voduff/baby-speed-attribute
🤖 Agent: speed_bonus now reads finalize::transient, the same minecraft:baby modifier /attribute reports, so modifier remove slows the baby and a baby piglin gets 1 + 0.2f, not 1.5.
2026-11-09 19:30:24 +01:00
Ludovic Marie 1cce1cbf31 Merge pull request #14006 from Ragbie/fix/mob-fall-clip
🤖 Agent: Mob::do_move now casts Entity.move's FALLDAMAGE_RESETTING/WATER ray only when fall_distance != 0 and the move is a block or more, so it costs nothing on an ordinary tick; boats share the same helper and now query the water on a tagged block too.
2026-11-09 19:30:20 +01:00
Ludovic Marie ed5da43953 Merge pull request #13993 from MrCloudy2/fix/9325-piston-arrow-release
🤖 Agent: release_after_block_move runs AbstractArrow.move's shouldFall check after every non-SELF move, including a push limitPistonMovement or the collisions cut to nothing; #10243's part is already on main, so the net change is piston_push.rs plus the new harness.
2026-11-09 19:30:16 +01:00
Ludovic Marie 3295c54097 Merge pull request #13986 from MSedgeMC/fix/loot-seed-numeric
🤖 Agent: TagValueInput.getLongOr takes any NumericTag (longValue), so an Int LootTableSeed now seeds the roll as in vanilla instead of falling back to the position seed.
2026-11-09 19:30:11 +01:00
Ludovic Marie d0da7ec157 Merge pull request #14113 from MrCloudy2/fix/piston-push-entity-colliders
🤖 Agent: the extra cost is one O(mobs + boats) scan per body a moving piston or shulker lid pushes, the same set item_entity_collisions gives items; the new harness pins the snowball stopping at the boat's side.
2026-11-09 19:27:05 +01:00
Ludovic Marie 042d2ce46a Merge pull request #13950 from horizon-rust/split/friends-tunnel-hook
🤖 Agent: the engine keeps only the friend: address form; a build without a resolver refuses such an address with the same invalid_address key the old cfg(not(feature = "friends")) path used, so reconnect still never retries it.
2026-11-09 19:27:02 +01:00
Ludovic Marie 8f756e8487 Merge pull request #14090 from horizon-rust/fix/git-slim-test-deterministic
🤖 Agent: git 2.5x runs a detached geometric auto-maintenance after each commit that gc.auto=0 does not stop; the test now runs git with no user/system config and maintenance.auto=false, so the 12 packs it counts stay 12 (desktop: 10/10 failures before, 10/10 OK after).
2026-11-09 19:26:58 +01:00
Evan-lhm 3f1a50ccb2 Merge remote-tracking branch 'origin/main' into rt/ray-query-opt-in
# Conflicts:
#	crates/horizon-client/Cargo.toml
2026-11-09 19:25:47 +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 3dcfd7276b Merge remote-tracking branch 'rv3n/14142' into rv3-fix-14142 2026-11-09 19:21:20 +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 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 agentandClaude Opus 5.5 bfbf06a439 Fall flying wall hit: compare the speed before updateFallFlyingMovement
LivingEntity.travelFallFlying takes lastSpeed before the glide update, as
move_h does; the post-update speed left move_h unused (verify failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:19:28 +01:00
Horizon-agent 723c4e7c2b Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 19:18:44 +01:00
Horizon agentandClaude Opus 5.5 b7b137a829 docs/layers.md: the exception table names Hooks::tunnel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:39 +01:00
Horizon-agent c32f43b4b0 Merge remote-tracking branch 'origin/main' into pr-13800 2026-11-09 19:18:37 +01:00
Evan-lhmandClaude Opus 5.5 75071c8b9b gfx.rs may not grow: ray_query is declared beside its one user
Its `mod` line moves from gfx.rs (over 3000 lines, docs/layers.md#file-size) into
surface_setup.rs, the device request that reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 725208d8de Merge branch 'split/friends-screen-glue' into split/friends-join-official
# Conflicts:
#	crates/horizon-hooks/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:18:32 +01:00
Horizon-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +01:00
Ludovic Marie 3516aa9641 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue 2026-11-09 19:18:13 +01:00
DUzzL 971f341977 Keep lod.rs from growing 2026-11-09 19:17:51 +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 agent cd362fb354 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
2026-11-09 19:15:23 +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
Ludovic Marie 216eca2f51 Merge pull request #14030 from horizon-rust/layers/registries-spec
🤖 Agent: docs only (docs/layers.md): the Platform registries spec as built in #14036/#14070/#14081/#14096/#14110 (HUD place() returns None under F3, seat() for splitscreen), and the launcher/horizon-server exception rows removed after #13905.
2026-11-09 19:15:10 +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
Ludovic Marie 7dccd1fa39 Merge pull request #13629 from MSedgeMC/fix/bee-anger-target
🤖 Agent: the earlier verify failure was main's options_ui.rs growth measured against this branch's old base; with main merged in it passes, and mob_ai passed with this change on union19b, so a stung player stays the bee's angry_at through the chunk save as Bee.addAdditionalSaveData writes it.
2026-11-09 19:13:23 +01:00
Ludovic Marie e84e5b2dd3 Merge pull request #8230 from voidsipp/fix/8001-preparer-lock
🤖 Agent: the prepare lock now unlocks explicitly on drop, so a child that inherited the descriptor between fork and exec no longer holds the flock past the preparation (#8001); the new test holds a dup'd descriptor to show it.
2026-11-09 19:13:19 +01:00
Ludovic Marie 7b9f3f8e83 Merge pull request #13910 from Skr1zzzz/split-visual-clocks
🤖 Agent: pure move; pub(super) from render_tick.rs is narrower than it was in game.rs, but the only mention outside game/ is a doc comment in clock.rs, so nothing loses access.
2026-11-09 19:13:15 +01:00
Ludovic Marie c9d73d5bea Merge pull request #13954 from horizon-rust/split/official-servers-module
🤖 Agent: the official and partner server entries now live in official_servers.rs behind a lib horizon-extras feature, and servers.dat's lock moved to horizon_protocol::servers::lock(); a plain build adds no server and carries no afri-network address.
2026-11-09 19:13:11 +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 56b703899c hub.rs: keep the chat module's doc comment on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:09:30 +01:00
Ludovic Marie 7e6d2121b7 Merge pull request #14095 from vivescene/perf/client-mob-skips
🤖 Agent: both skips only move side-effect-free checks (box intersection, empty effect lists) ahead of the lookups; the set and order of mobs that push or swirl is unchanged.
2026-11-09 19:08:49 +01:00
Ludovic Marie a89865b1c5 Merge pull request #14094 from MSedgeMC/fix/book-title-centered
🤖 Agent: text_start_x is EditBox.updateTextPosition's centred textX ((width - text width) / 2 from getX), and position_at measures the click from the same start, as findClickedPositionInText does.
2026-11-09 19:08:44 +01:00
Evan-lhmandClaude Opus 5.5 63a6a92bef Ray tracing on request is a Horizon extra (#13780)
As ludovic111 asked on #13780: asking the device for ray queries, and the log line
saying why it is on or off, happen only in a `horizon-extras` build
(horizon-client's feature now enables horizon-client-render's). A plain build
requests the device it requested before, whatever HORIZON_RAY_TRACING says
(`only_an_extras_build_asks_for_ray_tracing`, run with and without the feature).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:35 +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
Evan-lhmandClaude Opus 5.5 cf93d0dd3a Renderer: ask for hardware ray queries only with HORIZON_RAY_TRACING=1 (#13780)
Step 1 of #13780 (carrying on the #1180 spike): the device requests wgpu's
EXPERIMENTAL_RAY_QUERY when the variable is set and the adapter has it, and
logs why it stays off otherwise. Nothing draws with it yet; without the
variable the device opens exactly as before. The unsafe opt-in lives in
horizon-hdr, which already holds the GPU calls the game crates can't make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:49 +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 c51a5eec3f Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 18:01:53 +00:00
MSedgeMC 2e9f807458 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 18:01:23 +00:00
DUzzL 21aabee4c0 Sync far-terrain mesh samples when kept tiles return 2026-11-09 19:00:58 +01: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 7be13f1e9a Merge upstream/main 2026-11-09 18:00:20 +00:00
MSedgeMC d56b769df5 Merge upstream/main 2026-11-09 18:00:08 +00:00
Horizon agentandClaude Opus 5.5 ff83eccf6a AttachFace: unsigned_abs on the remainder, as clippy asks, within the line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:17 +01:00
Ludovic Marie c8e48f9cea Merge pull request #14089 from Mik00000/perf/particle-collision-cache
🤖 Agent: ParticleLevel::block is World::block with a one-chunk Cell in front (same air fallback), and the other edits (lava smoke, bubble removal, lifetime_alpha) keep their random draws and results. Queue BEFORE 14097 (its Ctx impl sits beside this PR's Ctx<'a, 'l>).
2026-11-09 18:59:04 +01:00
Ludovic Marie bdb6452da9 Merge pull request #14073 from alexcatdad/nix-packages
🤖 Agent: packaging only; Cargo.lock has no git sources, so buildRustPackage needs no outputHashes, and nothing the source filter drops (launcher/, nix/, docs pages) is read by the build.
2026-11-09 18:58:58 +01:00
Ludovic Marie 2ddd9705d9 Merge pull request #14059 from MSedgeMC/fix/port-tooltip
🤖 Agent: PORT_INFO_TEXT is drawn after every widget so it sits on top, and a remote world (no port box) gets none.
2026-11-09 18:58:53 +01:00
Ludovic Marie 868d3b76a5 Merge pull request #14058 from MSedgeMC/fix/social-tooltip-delay
🤖 Agent: the delay is per button identity (row id + kind) with vanilla's strict elapsed > 500 ms, and costs one Instant::now() per frame on the social screen only.
2026-11-09 18:58:47 +01:00
MSedgeMC f2718e086d Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:58:35 +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
Horizon agentandClaude Opus 5.5 e39ec11e59 Merge branch 'layers/packs-colors' into layers/packs-emissive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:37 +01:00
Horizon agentandClaude Opus 5.5 dc382b898a CONTRIBUTING: mod-related pull requests go to arismoko/horizon
The project decided that mod-API work (native hooks, native, content and
wasm mods, mod builds and the registry) is reviewed and merged on Ari's
fork. Say so where contributors look first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:43 +01:00
Horizon agentandClaude Opus 5.5 b5f396461e Merge origin/main into layers/packs-colors
Main's Polytone item bar colours (#fb354374e3) move behind the new
PackFormats::item_bars hook (glue pack_formats/item_bars.rs, test moved with
them), and environment.horizon_height joins the engine's Environment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:39 +01:00
Horizon agentandClaude Opus 5.5 e963d24058 Give main's new UI-nits test its peak_guessed field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:18 +01:00
Horizon agentandClaude Opus 5.5 3eec96a8b1 Format the shulker load and its test; abs() keeps the line within entity_nbt.rs's size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:15 +01:00
Horizon agentandClaude Opus 5.5 249e8db13c Keep lan.rs from growing: with_warden takes the tracker and gives the guest back
lan.rs is over 3000 lines and may not grow (tools/check_file_size.py): the
tracker now goes in on the line that picks the owner's guest, and the
comment moves to playerdata::with_warden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:53 +01:00
Nikita Maximilian Grimm f25a5afc00 Merge remote-tracking branch 'origin/main' into record-start-copy 2026-11-09 18:53:46 +01: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
Ludovic Marie b4f24319ac Merge pull request #14083 from horizon-rust/agent/remote-poll-move
🤖 Agent: pure move; poll_stationary_client, poll_remote and poll_remote_events go byte-identical from remote.rs to remote_poll.rs (a #[path] child of remote), only visibility and super::super::server_links change.
2026-11-09 18:53:19 +01:00
MSedgeMC 1d2dd56a06 Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:52:56 +00: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
Horizon agentandClaude Opus 5.5 22f3da10c0 Telemetry server: bundle SQLite 3.54 through rusqlite 0.41
Its own workspace still resolved rusqlite 0.37 -> libsqlite3-sys 0.35,
which bundles a SQLite older than 3.54 (RUSTSEC-2026-0459). rusqlite 0.41
resolves libsqlite3-sys 0.39.1, as the root workspace already does.
rusqlite 0.38 moved the u64/usize conversions behind fallible_uint: turned
on, so the stored values convert as before (an error past i64::MAX).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:51:42 +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
Evrim Cicek c992f24496 Merge remote-tracking branch 'origin/refactor/minecarts-split' into refactor/minecarts-split 2026-11-09 20:49:53 +03:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Evrim Cicek 005249b984 Merge remote-tracking branch 'upstream/main' into refactor/minecarts-split
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 20:48:40 +03:00
Horizon agentandClaude Opus 5.5 63dae1216c SeenGrid: group the bench seed's hex digits (clippy), say when candidates scans all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:48:39 +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
Ludovic Marie 962f5949ac Merge pull request #13613 from vnsmoreira/fix/command-block-last-output
🤖 Agent: LastOutput is now saved as a component, keeping the red, click-to-suggest feedback of a failed command (BaseCommandBlock / ComponentSerialization.CODEC). The case keeps to failed commands, so no new score holder changes the later /scoreboard players list order. command_exec: 19063/19065; the 2 left are the zombie PersistenceRequired pair, which fails the same way on main 8321f11360.
2026-11-09 18:46:17 +01:00
Ludovic Marie 5e9024e0a1 Merge pull request #14063 from TheDanikReal/main
🤖 Agent: all 1266 entries keep their {placeholders}; translation file only, approved by a Team member who reads it.
2026-11-09 18:46:13 +01:00
Ludovic Marie 559c2d4e81 Merge pull request #14062 from MSedgeMC/fix/sign-messages-codec
🤖 Agent: a non-list messages now empties the side as SignText.CODEC does; still open: 26.3 also fails a side whose messages are missing or not exactly 4 (listOf(4, 4)), a follow-up.
2026-11-09 18:46:09 +01:00
Ludovic Marie ee449e0ef2 Merge pull request #13734 from RINNECODER/up10/campfire-smoke-limit
🤖 Agent: campfire smoke now goes through the particle limiter like vanilla's; marker_particles 2187/2187 pass on a union with main.
2026-11-09 18:46:05 +01:00
Ludovic Marie dc3d97ce25 Merge pull request #13728 from DUzzL/fix/13673-cured-villager-gear
🤖 Agent: cure drops and cursed gear go to the inventory as in vanilla; zombie_cure_sound 804/804 pass on a union with main.
2026-11-09 18:46:00 +01:00
Ludovic Marie cab5f23234 Merge pull request #13724 from DUzzL/fix/13672-cushion-lava
🤖 Agent: the cushion now takes lava damage at the check interval, as vanilla does; cushion_placement (2852 cases), fall_damage and living_fall pass on a union with main.
2026-11-09 18:45:56 +01:00
Ludovic Marie 2c5d1990ce Merge pull request #13957 from horizon-rust/layers/packs-sky
🤖 Agent: OptiFine custom sky now reaches the engine only through pack_hooks::PackFormats::custom_sky, resolved once per pack load (the per-frame draw keeps the same layer list and shader). layer plan M6 step 1. verify green on this head; no vanilla behaviour changed (parity.py selects 633 harnesses only because game.rs is touched; none observes pack skies).
2026-11-09 18:45:52 +01: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 eb3157d251 Launcher: the Data folder row keeps its Open button beside Move
The glass About card's row had Open; building location_row there dropped
it. location_row's action is now Open and Move side by side (only the
glass page builds it: the old settings page is unreachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:54 +01: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
MSedgeMC d577d49a78 port_map: the trapdoor ladder and the dismount event rows are ports
`Mob::on_climbable` takes an open trapdoor over a matching ladder, as
`climbing::on_climbable` does for the player, and `Game::mount_events`
posts `entity_dismount` for players and mobs alike. Both rows still
described the old gaps.
2026-11-09 17:44:34 +00: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 agent 8a9bd15a3d Merge remote-tracking branch 'origin/main' into rv3-fix-14129
# Conflicts:
#	docs/graphics.md
2026-11-09 18:43:16 +01:00
Horizon agentandClaude Opus 5.5 a40f3d82c1 A shulker with a negative colour byte draws white, as DyeColor.byId does
Shulker.getColor treats the byte as signed: 16 and above are undyed, and a
negative id reaches DyeColor.byId, whose OutOfBoundsStrategy.ZERO gives
white. Keeping the raw byte (255 for -1) sent it to the undyed texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:45 +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 agent db8048a1b6 Merge branch 'layers/pages-registry' into layers/hud-layers 2026-11-09 18:40:42 +01:00
Horizon agent 7383ff1908 Merge branch 'layers/pages-slider' into layers/pages-pause 2026-11-09 18:40:40 +01:00
Horizon agent 4f4c87df64 Merge branch 'layers/pages-registry' into layers/pages-slider 2026-11-09 18:40:39 +01:00
Horizon agent 6676c3a9a9 Merge remote-tracking branch 'origin/main' into layers/pages-registry 2026-11-09 18:40:38 +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
Ludovic Marie c2fb43b761 Merge pull request #13773 from TheMrZZ/fix/camel-jump-entity-action-2
🤖 Agent: the event is posted only after start_riding_jump accepted the jump, so a refused or unsaddled jump stays silent as in Camel.handleStartJump; the new harness now gates only on its non-diag rows (0b5a2c1). The functional change is ~20 lines, the rest is tests and the harness.
2026-11-09 18:39:52 +01:00
Ludovic Marie 6cfffc5daa Merge pull request #14067 from DUzzL/fix/13934-snow-golem-trail
🤖 Agent: isAir plus SnowLayerBlock.canSurvive's two tags (cannot_support first, then support_override, then the full top face), four tag lookups per snow golem tick.
2026-11-09 18:39:48 +01:00
Vinícius MoreiraandClaude Opus 5.5 af3c22b9f1 Post effects: a pack's post chain reads the world's depth
The world pass discarded its depth unless a shader pack, TAA, OIT or a
native mod read it, so a resource pack's post chain that samples
minecraft:main with use_depth_buffer read cleared depth (0, the sky)
everywhere. The freepack's sun glare tests the sky around the sun in
that depth, so it shone through hills and walls. The depth is now kept
whenever post effects run, as GameRenderer hands them the main target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:31 -03:00
Vinícius MoreiraandClaude Opus 5.5 182eec2cb7 gfx: move_off_page moves to gfx/off_page.rs
A pure move that makes room for the next commit: `gfx.rs` is over 3000 lines and
may not grow (docs/layers.md "File size"). `move_off_page` leaves it unchanged, imported back
under the same path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:30 -03: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
Ludovic Marie b5e7227fba Merge pull request #14066 from vnsmoreira/packs/polytone-screen-time
🤖 Agent: screen_time advances in tick_particles (once per client tick, not per frame) and resets on a different Screen variant; two containers of the same variant share a count, a small gap next to the measured frames.
2026-11-09 18:36:04 +01:00
Ludovic Marie dbebf0f615 Merge pull request #14054 from Ragbie/refactor/entity-nbt-hanging
🤖 Agent: checked line by line: every removed line reappears in entity_nbt/hanging.rs; only mod hanging, the pub use, use super::* and the module doc are new.
2026-11-09 18:36:00 +01:00
Ludovic Marie 2377d42c58 Merge pull request #14049 from senjinthedragon/inactive-varyings
🤖 Agent: reads() is conservative (any token naming the varying keeps it), and hz_/horizon varyings always travel, so the only change is fewer locations; ~120 of the 539 lines are new, the rest moved verbatim into translate/varyings.rs.
2026-11-09 18:35:55 +01:00
Ludovic Marie 7607e92fea Merge pull request #14042 from NikitaMGrimm/fix/git-slim-test-portable
🤖 Agent: maintenance.auto=false is set only in the test's temporary repositories, and shutil.which("bash") finds the same bash on Linux and macOS, so CI is unchanged while Windows with git 2.54 stops failing.
2026-11-09 18:35:51 +01:00
Ludovic Marie fdc6ffdd39 Merge pull request #14025 from RLC248/fix/anger-end-time-roundtrip
🤖 Agent: anger_end_time is now the absolute value vanilla keeps (only startPersistentAngerTimer/stopBeingAngry move it), and around_tick costs two compares per NeutralMob tick; the h262 expectation 151→150 is the vanilla value for a NoAI wolf. Parity runs on this head before the merge.
2026-11-09 18:35:46 +01:00
Ludovic Marie efca8dab86 Merge pull request #13896 from k3yh/mod-fluid-mobs
🤖 Agent: with no content mod loaded, a swimming mob's tick no longer scans its box twice or reads each water cell's block again: the mod-fluid scan is gated on fluid::mod_fluids_loaded().
2026-11-09 18:35:41 +01:00
Ludovic Marie 39377abc12 Merge pull request #13888 from MSedgeMC/fix/slider-sprites
🤖 Agent: canChangeValue in 26.3 is set by setFocused (mouse or Tab) and flipped by Enter, not the drag; the sprites take can_change_value || dragging, so a drag no longer lights the track and a Tab-focused slider keeps vanilla's lit handle.
2026-11-09 18:35:36 +01:00
Horizon agentandClaude Opus 5.5 53d1afc5b1 Merge origin/main into #13999
world.rs: main's raycast cache and despawn view kept beside this PR's NearIter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:35:35 +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
Horizon agentandClaude Opus 5.5 a94b5a1b2e Extras sections: group_named's lifetime, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:34:21 +01:00
Ludovic Marie 3d00e7a360 Merge pull request #13897 from NikitaMGrimm/hdr-ui-nits
🤖 Agent: Auto keeps today's HUD at paper white, and an older hdrUiBrightness becomes that share of a hand-set Paper White in nits; main's output_apply test now asks for 150 nits under a reported 250 (0.6).
2026-11-09 18:32:10 +01:00
Ludovic Marie 04d5e418bf Merge pull request #13893 from NikitaMGrimm/hdr-page-no-shortcut
🤖 Agent: an old horizon-graphics.txt with hdrKey still loads (the parser skips unknown keys, pinned by the new test), and main's hdr_failed_start stays beside it after the merge.
2026-11-09 18:32:05 +01:00
Horizon agentandClaude Opus 5.5 b4c7f409d2 attribute_modifiers: read the amount as a double
AttributeModifier.amount is a double; going through float_value turned
0.1 into 0.10000000149. The test now uses 0.1 to pin it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:31:54 +01:00
Horizon agentandClaude Opus 5.5 3365fd6bc4 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:38 +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 c0da1ac2e1 Control socket sign: a read-only book still answers "not a book and quill"
begin_signing does nothing for BookScreen::View, so the catch-all arm
turned that error into a silent success.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:28 +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
Horizon agentandClaude Opus 5.5 45cefe3278 Pages: Place::Extras, a section of the Extras page (layer plan M5)
A crate above the engine adds rows to Horizon Settings' Extras page: they
join the page's group the section's title names, else a group of its own.
Auto-reconnect moves out on it next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:17 +01:00
Ludovic Marie cc606585ad Merge pull request #14046 from horizon-rust/layers/pages-extract
🤖 Agent: pure move, no behaviour change: native_rows, the native key wait (the two duplicated Act::NativeKey handlers now call one method) and the key binding go to options_ui_pages.rs, a child module that keeps access to the screen's private types; options_ui.rs 8441 -> 8391 lines.
2026-11-09 18:28:42 +01: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
Horizon agentandClaude Opus 5.5 032ae31f34 creatures.rs: mob_geometry moves to creatures/mob_geometry.rs (pure move)
mob_geometry and mob_geometry_inner, the per-frame drawing of mobs, players and
projectiles, move unchanged into a child module of creatures.rs, so a change
to them (#12276's client_entity_render) no longer grows creatures.rs, which is
over the 3000-line limit. Only the visibility (pub(in crate::game)), the
super:: paths one level deeper, and rustfmt's reflow of two lines follow the
new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:34 +01:00
Horizon agentandClaude Opus 5.5 47d55b5690 pack_post.rs: import BUFFERS instead of a local const
The revert re-added `const BUFFERS` and a blank line, so the file grew from
5859 to 5860 lines over the 3000-line limit (verify run 42650015942). The
import does the same in no extra line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:21 +01:00
Ludovic Marie 6d1235ca18 Merge pull request #13978 from DUzzL/fix/13936-rider-pickup
🤖 Agent: the riding pickup box is right (Player.aiStep: union with the vehicle's box, inflated 1/0/1). The partial-pickup half was not: Inventory.add returns true once any of the stack goes in, so playerTouch still calls take, awards ITEM_PICKED_UP with the stack's original count and calls onItemPickup. I restored that, using vanilla's count.
2026-11-09 18:23:59 +01:00
rdbtCVSandClaude Sonnet 5.5 db4a145e6b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:22:31 +00:00
MSedgeMC 59d958c09e port_map: the shulker's save reads wrap and narrow like vanilla
`tools/port_map/game2-002.tsv` said `Shulker.readAdditionalSaveData` reads
`AttachFace`, `Peek` and `Color`; the row now names the codec's wrapping and
the two raw bytes the port keeps.
2026-11-09 17:22:12 +00:00
MSedgeMC 1a2015e305 test: a shulker's saved face and lid bytes
`mob_from_nbt` is what `Game::load_mob` goes through. A shulker with
`AttachFace: 7b`, `Peek: -1b` and `Color: -1b` loads with `variant3 == 1`
(`abs(7 % 6)` is up), `counter == -1` and `color == 255`.

Without the coercion they load clamped: `left: 5`, `right: 1` for the face,
`left: 0`, `right: -1` for the peek and `left: 16`, `right: 255` for the
colour.
2026-11-09 17:22:12 +00:00
MSedgeMC 197172ef3c fix: a shulker reads its saved face and lid bytes as vanilla does
`Shulker.readAdditionalSaveData` reads three fields with different rules:

- `AttachFace` is `Direction.LEGACY_ID_CODEC`, whose `from3DDataValue` wraps
  the byte through `abs(id % 6)`;
- `Peek` is `getByteOr("Peek", 0)`, a raw byte put straight on the synched
  data;
- `Color` is `getByteOr("Color", 16)`, a raw byte too.

Horizon clamped all three to the setters' ranges (`0..=5`, `0..=100`,
`0..=16`): an `AttachFace` of 7 became east (5) instead of up (1), a negative
`Peek` became 0 instead of the byte vanilla stores, and a negative `Color`
became 0 instead of 255.

The load now wraps the face and narrows `Peek` and `Color` to bytes, exactly
as the codec and `getByteOr` do.
2026-11-09 17:22:12 +00: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
Ludovic Marie 8ef5c055b0 Merge pull request #12284 from Cheezer1656/upstream-gap/u-01-registry-name
🤖 Agent: the registry-name read goes through SyncedNames, the same builtin+dynamic tables the hub syncs to clients, so a mod sees the names a vanilla client gets; Hub::synced_name and interact_location moved out (file-size rule).
2026-11-09 18:21:15 +01:00
Ludovic Marie 6be9c07ce4 Merge pull request #13764 from TheMrZZ/fix/spawn-info-generator-sea-level-2
🤖 Agent: the sea level now follows the level's generator through levels.rs's locator swap, so flat worlds send -63 on login and respawn; lan.rs/lan_actions.rs kept from growing with two pure moves (ada4756).
2026-11-09 18:21:11 +01:00
Horizon agentandClaude Opus 5.5 c3c0e2d868 Pages pause: test imports, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:20:47 +01:00
JoansitohandClaude Sonnet 5.5 528d98bf36 Nautilus: feeding heals 2 x the food's nutrition
TamableAnimal.feed heals 2.0 * nutrition (1.0 without a food component),
not a flat 2 HP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:20:34 +01: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
Ludovic Marie d74c95f088 Merge pull request #13804 from Mik00000/perf/parallel-mob-ai
🤖 Agent: the section cache gives the same states as Chunk::block, including air for a missing chunk or section. With the contextual test now on the registry's flag (6ee08b4), the cached shape is used only where collision_shape_with_context would return it too, so each raycast voxel saves an Arc clone and a lookup without changing a hit.
2026-11-09 18:18:11 +01:00
JoansitohandClaude Sonnet 5.5 9d6d292348 Keep the owner's warden spawn tracker through a save
store_owner wrote the tracker into level.player_extra, then playerdata::store
replaced that with the guest's loaded snapshot, losing the warning level and
cooldown. Put the tracker into the owner guest's saved compound first.

Part of #9965

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:18:08 +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
Ludovic Marie 9d5dbbd2f5 Merge pull request #13995 from horizon-rust/agent/registry-revoked-version-removal
🤖 Agent: a registry version revoked by sha256 may now keep its entry, so a lost signing key can be rotated (baritone #276); the PR's tests, clippy and fmt were clean.
2026-11-09 18:15:20 +01:00
Ludovic Marie 3a2bf1f4f8 Merge pull request #13758 from RINNECODER/up11/mesh-upload-backpressure
🤖 Agent: pending_uploads drains every frame independently of meshing (world_update's budget loop), so counting it against the mesh room is pure backpressure with no stall path; the room counts columns while the backlog counts sections, which only makes the hold stricter at wide distances.
2026-11-09 18:15:16 +01:00
Ludovic Marie d49eeccd3f Merge pull request #13746 from vnsmoreira/packs/polytone-item-bars
🤖 Agent: item_bar_color takes a read lock and scans ITEM_BARS per drawn damage bar; with no Polytone item modifiers that Vec is empty, so vanilla packs pay nothing measurable. The two stacked commits are already on main (#13703, #13704).
2026-11-09 18:15:09 +01:00
Ludovic Marie 7e13db2271 Merge pull request #14036 from horizon-rust/layers/settings-store
🤖 Agent: settings store only routes lines at load/save time: with no store registered Settings::load/save read and write exactly what they did (horizon-extras.txt unchanged, no new file); a store's own file keeps lines no store owns, the shared horizon-extras.txt does not (Extras rewrites it).
2026-11-09 18:11:55 +01:00
Horizon agentandClaude Opus 5.5 7973f9935b Pages: Place::Pause, an entry in the pause menu
A page registered with Place::Pause gets a full-width button in the
pause menu, above Options and under Splitscreen's settings, labelled
with its title; it opens the options screen at the page. The split row
and the pages' rows share one list (pause_rows), so the grid, the icons
and controller focus place them alike. With nothing registered the
pause menu is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:43 +01: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
Horizon agentandClaude Opus 5.5 d119d6da2d horizon-friends game: drop an unused import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:50 +01:00
Horizon agentandClaude Opus 5.5 4bc93d9a5f particles.rs: send_particles and create move to particles/spawn.rs (pure move)
The two entry points that bring particles into the engine move unchanged into
a child module, so a change to them (#12277's client_particle hook) no longer
grows particles.rs, which is over the 3000-line limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:42 +01:00
Horizon agentandClaude Opus 5.5 671af567f4 docs/layers.md registries: status with the PRs, Page::tooltip
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:09:15 +01:00
Ludovic Marie 389d15bda0 Merge pull request #14028 from horizon-rust/split/discord-standalone
🤖 Agent: discord_presence.rs now uses only horizon_command's GameMode and a max the caller passes (Play::Hosting { players, max }, still lan::MAX_PLAYERS), so the presence client can move to a Horizon crate as a plain file move.
2026-11-09 18:09:04 +01:00
Ludovic Marie 4ae696550f Merge pull request #13769 from MSedgeMC/fix/jukebox-song-component
🤖 Agent: insert path and the client's jukebox_takes now read the stack's jukebox_playable like the comparator and song tick (pushed 2fe3429), so a removed component is refused and any item given one is taken, as JukeboxBlock.useItemOn does.
2026-11-09 18:08:59 +01:00
Ludovic Marie e92dc8108d Merge pull request #13722 from RINNECODER/up10/stats-mined-items
🤖 Agent: rows and the Mined column match ItemStatisticsList (Block.asItem, BlockItem.getBlock) and the comparator's sortOrder × (value, Item.getId); I pushed 8396106b2e: the sort looked each row's registry id up with a linear scan of ~1500 items, several times a frame while the screen is open, now one HashMap per sort.
2026-11-09 18:08:53 +01:00
iseaprojectsandopencode 1f19e94d74 Send dye and imbue recipes to the recipe book
`ServerRecipeBook.addRecipes` sends one entry per display of a recipe.
`book_entries` had no writer for `crafting_dye` or `crafting_imbue`, so it
fell through to "no display": a learnt leather-dyeing or tipped-arrow
recipe never reached the player's book, though removing it sent the right
Remove packet.

- `crafting_dye` writes `DyeRecipe.display`: the target and the dyes kept
  to the DYE component, the `minecraft:dyed` demo of the two, then a
  crafting table; the placement is the target and the dye.
- `crafting_imbue` writes `ImbueRecipe.display`: the 3x3 shaped display of
  four materials, the `with_any_potion` source, four materials again, and
  the result wrapped in `with_any_potion`; the placement puts the source
  in the middle cell.

Both are single-display recipes, so they also get their ghost display now.
Covered by unit tests of the decoded entries and by the all-real-recipes
round trip, and the variants parity harness now grants and takes back
leather helmet dyeing and tipped arrows beside map cloning.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 13:08:47 -04:00
voduffandClaude Opus 5.5 fcbeaefd37 Baby mobs move by their minecraft:baby modifier, not a fixed 1.5x (#11994)
ai::speed_bonus hard-coded 1.5 for baby zombies and piglins, so a baby
whose minecraft:baby modifier was removed with /attribute kept running
fast, and a baby piglin ran at 1.5x where Piglin.SPEED_MODIFIER_BABY is
+0.2. It now applies finalize::transient's MovementSpeed modifier
(ADD_MULTIPLIED_BASE), the one /attribute reads.

Part of #11994

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:16 +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 agentandClaude Opus 5.5 8bf0614d9d Pages: slider test checks the position shown; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Ludovic Marie 1d6e973ab8 Merge pull request #13723 from MSedgeMC/fix/hotbar-carried
🤖 Agent: matches checkHotbarKeyPressed's getCarried().isEmpty() gate; merged main in, where a double-click test had landed at the same spot, both tests kept.
2026-11-09 18:03:57 +01:00
Horizon agentandClaude Opus 5.5 83b3c9306f Docs: forks may not use Horizon's Microsoft client ID
Horizon's Azure app registration and Mojang's approval of it cover Horizon's
own builds only. Forks and derived launchers register their own app, seek
their own approval, and must not ship or fetch Horizon's client ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:03:45 +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 agent e6184afbe2 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	docs/forking.md
2026-11-09 18:01:41 +01:00
Horizon agentandClaude Opus 5.5 96c5616505 Pages: sliders and per-row tooltips
A registered page's Slider row is one of the options screen's own
sliders (Opt::Page(page, row)): dragging, the arrow keys, narration,
focus and search work as for the engine's options, and the page hears
Act::Slide. Page::tooltip (default none) gives a row's hover text, which
may follow its value.

The registered pages are read from horizon_hooks::pages directly (a
test sets its own per thread), and horizon_page_tooltip moves to
options_ui_pages.rs: options_ui.rs shrinks again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:00:36 +01:00
Bairon M. 7766619ecb Relay: cap unknown codes per nameplate
An invite code is eight Crockford symbols: four nameplate symbols the
relay routes by (20 bits) and four secret symbols the host checks (20
bits). The relay sees only the nameplate, so it can count joins whose
nameplate does not exist, not wrong secrets for one that does; the host
already retires a code after five of those.

This adds a budget for unknown codes for one nameplate (5 a minute,
burst 10), spent whichever address asked and checked before the lookup:
without it, one address set can take the whole global unknown budget
for a single short code. The check records nothing for a nameplate it
has not seen; only spending an unknown code creates the entry, so a
join refused by another budget leaves no state behind.

Verified: cargo test -p horizon-relay (31 tests) and clippy
--all-targets with -D warnings.
2026-11-09 13:58:12 -03:00
MSedgeMC ec3e4df6e3 port_map: EditBox.setCentered 2026-11-09 16:55:26 +00:00
MSedgeMC a0f02b61ea test: a centred edit box starts its text in the middle
The title box is set centred; the test pins the offset for an empty, a short
and an odd-width value, and that the sign screen's title box asks for it.
2026-11-09 16:55:26 +00:00
MSedgeMC 7f2e64b603 fix: the book signing title box centres its text
BookSignScreen sets its 15 character title box centred, so the title and the
caret sit in the middle of the 114 pixel box. EditBox had no centred mode: the
text (and a click on it) started at the box's left edge.
2026-11-09 16:55:26 +00:00
Horizon agentandClaude Opus 5.5 6e21ce9476 Joining a friend leaves the engine: horizon-friends offers the tunnel through Hooks::tunnel
The tunnel extension point moves to horizon-hooks (re-exported by horizon_platform::hooks),
offered by a hook instead of a startup registration. horizon-friends' new game feature holds the
resolver, and horizon-official registers it with the other Horizon parts. The friends harness
runs the official executable. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:14 +01: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
valuttaandClaude Opus 5.5 861b9e7b16 Client tick: skip far mobs before the pushes' lookups and effectless ones before their size
remote_pushes looked up every mob's player entry and scoreboard team
before testing whether its box meets the player's; effect_swirls worked
out every mob's width and height before seeing it had no effects. Both
skips come first now, for the same pushes and particles. At 10,000 pigs
the client tick takes about a quarter less of the main thread.

Fixes #14053

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:53:00 +01:00
MrCloudy2andClaude Opus 5.5 365a44ff91 Harness: a body a piston pushes stops at a boat in its way
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:52:33 +01:00
rdbtCVSandClaude Sonnet 5.5 358456bb02 Merge main into refactor/minecarts-split
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:52:32 +00:00
Horizon agent 07068153c6 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue
# Conflicts:
#	docs/forking.md
2026-11-09 17:52:02 +01:00
Ludovic Marie e2a17164ca Merge pull request #14017 from horizon-rust/fix/prepare-folder-with-libraries
🤖 Agent: prepare now picks a Minecraft folder that holds every library of the version, which is what the azure-json failure in #13872 was missing; the prepare tests pass.
2026-11-09 17:51:37 +01:00
Ludovic Marie 0d24192a23 Merge pull request #13945 from MSedgeMC/fix/container-first-release
🤖 Agent: checked against 26.3's bytecode: AbstractContainerScreen's constructor sets skipNextRelease = true, so the release of the click that opened the chest no longer picks up the slot under the pointer. I merged main (it conflicted with #13946's test) and kept both tests.
2026-11-09 17:51:32 +01:00
MSedgeMC b75193985b port_map: WorldOptionsScreen.init's port box tooltip 2026-11-09 16:51:28 +00:00
MSedgeMC 1a07a02990 port_map: the social row buttons' tooltip delay and the PlayerEntry row 2026-11-09 16:51:22 +00:00
MSedgeMC f5c6b7dfd1 port_map: BookSignScreen.init fills the title box with the kept title 2026-11-09 16:51:15 +00:00
Horizon agent 8d5dd89421 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game.rs
#	tools/layers.json
2026-11-09 17:51:01 +01:00
MrCloudy2andClaude Opus 5.5 6280d2630c A body a piston or a shulker lid pushes stops at a boat or a shulker
Entity.collide's getEntityCollisions: a pushed entity collides with the
entities it can collide with (canCollideWith: a boat, a living shulker, a
happy ghast held still), not only with blocks and the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:50:23 +01:00
Ludovic Marie e5814ae449 Merge pull request #13967 from horizon-rust/split/cosmetics-gather
🤖 Agent: pure move: emotes.rs and emote_wheel.rs keep their bodies under game/cosmetics/, their state joins Cosmetics, and game.rs loses its two emote fields (9 lines shorter)
2026-11-09 17:48:57 +01:00
Mik00000 c221aa9ec9 perf(render): cache chunk lookups inside ParticleLevel
Maintainer feedback from PR #13906 requested keeping signatures identical and
placing the one-chunk lookup cache directly inside ParticleLevel via
Cell<Option<((i32, i32), Option<&'a Chunk>)>>, avoiding threading _cached twins
and preventing particles.rs from growing beyond base size.

- Store cached_chunk inside ParticleLevel and read through ParticleLevel::block.
- Decouple Ctx lifetime parameter in polytone.rs (Ctx<'a, 'l>) so ParticleLevel's
  invariant 'a does not constrain stack evaluation borrows.
- Keep particles.rs strictly under line size limits (5,195 non-test lines vs 5,197 base).
- Passes check_file_size.py, cargo fmt, clippy -D warnings, and particle unit tests.
2026-11-09 18:47:29 +02:00
Horizon agentandClaude Opus 5.5 0568b2beff tests: keep git's background maintenance out of test_git_slim
Since git 2.5x, `git commit` starts `git maintenance run --auto` detached
after every commit, and its default strategy repacks geometrically even
with gc.auto=0. The packs the tests build were folded while they were
counted: 12 built, 3-6 found on the desktop (git 2.54, every run), and
intermittently in CI verify (run 42647063573 on #13970).

The tests now run git with no user or system config, no inherited GIT_*
variables, and gc.auto=0, maintenance.auto=false, no detached gc. The
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 aa741b3570 HDR settings: on Windows, the HDR page asks to calibrate
On Windows, Auto takes the display's peak from DXGI's MaxLuminance, which is
mostly the display's EDID figure and often runs high. The HDR page still said
the display was already set up ("Calibrate Anyway..."), though calibration's
Peak step works there, since PQ passes through unchanged.

Now the Windows reading marks its peak as a guess (`Reported::peak_guessed`),
and the page's Calibrate row reads "Calibrate..." and says the reported peak is
often too high, while Peak Brightness and Paper White stay on Auto. Auto still
uses the reported values, which beat the 1000-nit default. Once the player has
calibrated or set either value, the row offers "Use Automatic Setup" as before.
There the Peak Brightness row no longer says it is set up for the display:
its Auto is the guess. Paper White, the player's own slider, still does.
Compositor reports (Wayland) and macOS, which reports no nits, are unchanged.

The setup decision moves next to `Reported` (`Graphics::hdr_setup`), out of
graphics_ui.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:16 +01:00
Horizon agentandClaude Opus 5.5 3e77ec425f remote.rs: the packet poll loop moves to remote_poll.rs (pure move)
poll_stationary_client, poll_remote and poll_remote_events move unchanged
into game/remote_poll.rs, a module of remote.rs, so a change to the loop
(#12271's cancellable packets) no longer grows remote.rs, which is over the
3000-line limit. Only the visibility (pub(in crate::game)) and one path
(super::super::server_links) follow the new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:28 +01:00
Ludovic Marie c80f5ae218 Merge pull request #13864 from Skr1zzzz/split-block-particles
🤖 Agent: pure move of crack_particle and particle_sprite into the existing game/block_particles.rs, bodies identical. I merged main in (daf537e): main's game.rs with only these two fns removed, after #13856/#13877 moved their neighbours.
2026-11-09 17:44:43 +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
Horizon agentandClaude Opus 5.5 21860eefba HUD: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:06 +01: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
Horizon agentandClaude Opus 5.5 0ccd2a4397 docs/layers.md: the launcher and horizon-server reach the engine through platform (#13905)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:43:12 +01:00
Horizon agent dd8214d3a3 Merge remote-tracking branch 'origin/main' into layers/registries-spec 2026-11-09 17:43:11 +01: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
Ludovic Marie e5dfe2162a Merge pull request #13890 from Mik00000/feat/mod-async-worker
🤖 Agent: this answers #13740's review: the queue is per Game (window and server thread apart), jobs run on their own horizon-mod-worker pool, and panics are contained with the mod named. The fragment stamps cleanly (tests.test_stamp_native_hooks OK), and the test-module reorder in game.rs is what keeps that file from growing.
2026-11-09 17:38:44 +01:00
Ludovic Marie 92dfd34610 Merge pull request #13797 from MSedgeMC/fix/tempt-invisible-range
🤖 Agent: matches TargetingConditions.test: range * getVisibilityPercent floored at 2.0, with the targeter's type passed so mob heads count as they do for the other users of visibility_percent. Moving Tempt and the tests out of goals.rs is a pure move; that file is over 3000 lines and may not grow.
2026-11-09 17:37:28 +01:00
Ludovic Marie dea884f9c2 Merge pull request #13809 from vnsmoreira/packs/polytone-colormap-perf
🤖 Agent: reads_only counts any Root or Host expression as reading everything, so a colormap is only shared up a column when its axes can't see the height. Measured 2.16 ms down to 178 µs per section, with the same colour checksum. It carries #13806's commits; land it after that one.
2026-11-09 17:37:23 +01:00
Ludovic Marie 2d3ee9d7ef Merge pull request #13806 from vnsmoreira/packs/polytone-biome-colors
🤖 Agent: everything new runs once, when the mesh tables are built (biome_functions, Colormap::flat), and with no pack setting biome_colors the tables come back unchanged. horizon_height defaults to vanilla's 63, so without a pack the sky disc doesn't move.
2026-11-09 17:37:19 +01:00
Ludovic Marie 2b80362795 Merge pull request #13799 from NikitaMGrimm/parity/piston-falling-block
🤖 Agent: matches FallingBlockEntity.tick: the (0.7, -0.5, 0.7) landing slowdown happens before the MOVING_PISTON test, and the 0.98 drag at the end of the tick. Neither placement nor cancelDrop runs there, so the block stays falling for the piston to push. The new command-exec group is now in CI's parity run.
2026-11-09 17:37:14 +01:00
Ludovic Marie 9c10e5bfe1 Merge pull request #12175 from Cheezer1656/upstream-gap/u-18-own-inventory
🤖 Agent: own_inventory() reads the player's Inventory.items 0-35 straight from the synced inventory, so it answers the same with a chest up or the window's own inventory, where menu_stacks() is None; the client test checks hotbar slot 0 with a menu open. main merged in (docs conflict beside slot_roles).
2026-11-09 17:37:10 +01:00
Ludovic Marie b524e47aa8 Merge pull request #13865 from MSedgeMC/fix/pack-reload-overlay-hold
🤖 Agent: the 1000 ms hold matches LoadingOverlay.isReadyToFadeOut's fadeIn gate, counted from spawn rather than from the first drawn frame (a frame's difference). The new test slept 50 x 1 ms against an 80 ms hold, which a loaded runner can overrun, so I pushed 308bf3d: it holds for an hour, waits for the worker, then lifts the hold.
2026-11-09 17:37:06 +01: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
Ludovic Marie 9a8927755c Merge pull request #13882 from NikitaMGrimm/ci/ratchet-checks-merge-parent
🤖 Agent: verify checks out with fetch-depth 0 and runs on refs/pull/N/merge, so HEAD^1 is the main it lands on; pull_request.base.sha goes stale after a push, which is the false size failure in #13874.
2026-11-09 17:37:01 +01:00
Ludovic Marie f6d492dbb4 Merge pull request #13895 from k3yh/mod-fluid-entities
🤖 Agent: vanilla's paths don't change with no mod fluid loaded (holds() falls back to the caller's tags, add_mod_tags adds nothing). add_mod_tags does run on every entity's fluid update, but it's a 5-row scan then.
2026-11-09 17:36:56 +01:00
Horizon agent 9fd16efb45 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 17:34:46 +01:00
Horizon agent 34b5df1397 Merge branch 'layers/packs-sky' into layers/packs-colors
# Conflicts:
#	crates/horizon-client-render/src/lib.rs
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:35 +01:00
Horizon agent 2a018699ac Merge remote-tracking branch 'origin/main' into layers/packs-sky
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:13 +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
Ludovic Marie 515a26c926 Merge pull request #13905 from horizon-rust/split/launcher-server-platform
🤖 Agent: the launcher and horizon-server now reach the engine only through horizon-platform (feature-gated re-exports, so horizon-telemetry still builds without horizon-world); import paths only, no behaviour change; 7 layers.json exceptions removed.
2026-11-09 17:33:01 +01:00
Horizon agentandClaude Opus 5.5 74eaac0c21 docs/layers.md registries: status, seats, place gives None under the debug screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:31:50 +01:00
Ludovic Marie 64d6abf771 Merge pull request #13743 from vnsmoreira/packs/polytone-lead-texture
🤖 Agent: no change without a pack, since vanilla 26.3 ships no textures/entity/lead.png and the walked entity/ folder only picks one up from a pack; the per-row UV split keeps both faces' rows consistent (near and far quads swap top and bottom).
2026-11-09 17:31:41 +01:00
Ludovic Marie 950b8ed68b Merge pull request #13904 from NikitaMGrimm/tooling/file-size-skip-test-files
🤖 Agent: the TEST_FILE regex anchors on a path separator, so contests.rs and tests_util.rs still count, and the new test pins both sides.
2026-11-09 17:31:37 +01:00
Ludovic Marie c703484705 Merge pull request #13735 from RINNECODER/up10/chat-filter-mask
🤖 Agent: matches ChatListener.showMessageToPlayer and FilterMask.applyWithFormatting, including narrating the filtered text; one difference left on purpose: vanilla's nextClearBit can run past the text's end and print extra #s for a mask longer than the message, Horizon stops at the text, which no real server sends.
2026-11-09 17:31:32 +01:00
Ludovic Marie 4d300f6aed Merge pull request #13894 from NikitaMGrimm/fix/stations-test-item-table
🤖 Agent: Items::tags_only gave the sword max damage 0, so the anvil test checked nothing past that point. With the full table its trim and enchanting parts run for the first time, on prepared data only (CI has none).
2026-11-09 17:31:27 +01:00
Horizon agentandClaude Opus 5.5 b9efd702dc HUD: a registry for what crates draw on the HUD
horizon_hooks::hud (re-exported as horizon_platform::hud): a crate above
the engine registers a Layer that draws on a Canvas (size, tr,
text_width, text, fill, place). The extras' HUD pass draws the layers
after the minimap, PvP widgets and mods' boxes, stacked in the same
corners; a corner the debug screen covers gives None. With no layer
registered the engine builds no canvas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:30:53 +01:00
Ludovic Marie 93cee47a13 Merge pull request #13908 from Skr1zzzz/split-buckets
🤖 Agent: pure move into game/buckets.rs; leaving pick_fluid in game.rs for the open PRs that change it is the right call.
2026-11-09 17:30:19 +01:00
Ludovic Marie 627ac3c0ba Merge pull request #13725 from MSedgeMC/fix/screen-pauses
🤖 Agent: checked against 26.3's isPauseScreen overrides: SocialInteractionsScreen is false and the game-master block editors inherit Screen's true, so the table now matches; one match over every variant also makes a new Screen variant a compile error instead of a silent 'keeps ticking'.
2026-11-09 17:30:14 +01:00
Ludovic Marie d178d86b4d Merge pull request #13907 from Skr1zzzz/split-saving
🤖 Agent: pure move (only save_level and tick_world_icon gain pub(super)); the 4 port-map rows follow the code, and it stacks cleanly with #13887 and #13908 on main.
2026-11-09 17:30:09 +01:00
Ludovic Marie c8c0157d79 Merge pull request #13887 from Skr1zzzz/split-block-ticks
🤖 Agent: checked with --color-moved: the only lines that aren't moved are the pub(super) prefixes on sync_tick_chunks and tick_moving_pistons; it merges cleanly with main and with #13907/#13908.
2026-11-09 17:30:05 +01:00
Ludovic Marie c6c60fc163 Merge pull request #13886 from senjinthedragon/shadow-water-fallback
🤖 Agent: the plain shadow_water program is now translated on every pack load, even when the GPU takes the patched one; that's load time only, and the full-outputs GPU test shows the fallback only kicks in at the device's output limit.
2026-11-09 17:30:00 +01:00
Ludovic Marie 20c4ed3274 Merge pull request #13215 from pehe1/fix/11338-blocking-stack-durability
🤖 Agent: a blocking shield now loses durability from the stack it actually holds after a save round trip (item_nbt), and the dispenser harness fixture checks it; the merger waits for CI parity on this fork head.
2026-11-09 17:29:55 +01:00
Ludovic Marie 1610735fcd Merge pull request #13955 from horizon-rust/split/cosmetics-prep
🤖 Agent: anim::setup keeps its None fast path; an emoting player costs one dyn BodyPose call per pose, and set_playing only reallocates when the emote changes, not every tick
2026-11-09 17:29:50 +01:00
Ludovic Marie 139a42ba8b Merge pull request #13798 from NikitaMGrimm/hdr-auto-off-grace
🤖 Agent: GitHub's +1605 is stale. With #13623 merged, the real diff against main is gfx.rs and output_apply.rs, +433/-26. The grace costs one Option check per update_output and a single extra display read at the 500 ms deadline, so a frame with HDR off pays nothing. It also merges cleanly together with #13810.
2026-11-09 17:29:46 +01:00
Ludovic Marie 9477cb6d1e Merge pull request #13965 from MrCloudy2/fix/piston-projectile-bubble
🤖 Agent: reusing #12913's swept bubble-column pass for the piston move is the right shape: moveEntityByPiston ends in applyEffectsFromBlocks, so the push gets onInside/onAboveBubbleColumn as the projectile's own move does. I swapped drain(..).collect() for mem::take to clear clippy; the new harness runs in CI parity.
2026-11-09 17:29:41 +01:00
Ludovic Marie 60f42e7cdf Merge pull request #13979 from horizon-rust/fix/13108-dll-search
🤖 Agent: /DEPENDENTLOADFLAG:0x800 makes Windows load the launcher's import DLLs from System32 only, and tools/test_launcher_dll_search.py checks the flag in the built PE so a build change can't drop it.
2026-11-09 17:29:36 +01:00
Vinícius MoreiraandClaude Opus 5.5 5757de7e74 Merge main into #13613
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:28:51 -03:00
Horizon agentandClaude Opus 5.5 79cd63b8bc Pages: rustfmt, clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:27:08 +01:00
Horizon agent a2fb9bc76e Merge branch 'layers/pages-extract' into layers/pages-registry 2026-11-09 17:27:01 +01:00
Horizon agentandClaude Opus 5.5 1acc70eb43 Options pages: module order as rustfmt keeps it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:26:29 +01:00
MSedgeMC 916ab6b8b1 A reopened social screen starts its tooltips' wait again
The screen's widgets are new on every open in vanilla, so the half-second
wait starts over rather than resuming a hover from the last time.
2026-11-09 16:26:28 +00:00
MSedgeMC b56e58fde3 Document the sign side codec fallback 2026-11-09 16:24:35 +00:00
DUzzL 633169ea0f Snow golem trail: use isAir and the snow-layer support tags 2026-11-09 17:23:43 +01:00
alexcatdadandClaude Opus 5.5 0df07ea3cf Nix: build horizon-server, the engine, horizon-prepare and the client from the flake
The flake built only the launcher, for Linux. It now also builds the game's programs from this
workspace, with nixpkgs' rustPlatform and Cargo.lock, on x86_64-linux, aarch64-linux and
aarch64-darwin (nix/):

- horizon-engine: horizon-client --no-default-features, the headless game (no ALSA or udev);
- horizon-server, with horizon-engine linked beside it, where the server looks for the game;
- horizon-prepare, with a JDK 25 as its default JAVA_HOME and its root (research-lock.json, the
  exporters' sources) in share/: a wrapper copies that root somewhere writable unless --root is given;
- horizon-client with horizon-ctl, the libraries wgpu and winit open at run time on its rpath.

The launcher and the dev shell stay as they were, on Linux. docs/nix.md says how to use them. Nothing
of Mojang's is fetched or packaged. Part of #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:23:29 +02:00
TheDanikReal e527784bc3 Fix more Belarusian translations 2026-11-09 16:23:03 +00:00
Evrim CicekandClaude Opus 5.5 69ea634469 entity_nbt: re-export hanging with a glob
So a function added to hanging.rs later needs no line in entity_nbt.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:03 +03:00
MSedgeMC 5dd06d27cb The port box explains itself on hover
WorldOptionsScreen gives portEdit the PORT_INFO_TEXT tooltip, so hovering the
LAN port box says what it is; Horizon drew no tooltip for it. The box stays
visible with LAN off (only its editing stops), so the tooltip shows there too.
2026-11-09 16:21:49 +00:00
DUzzL 72c6784172 Launcher: give the glass Settings Data folder row the Move button 2026-11-09 17:21:46 +01:00
MSedgeMC 3c5a95239d A social screen row button's tooltip waits half a second
PlayerEntry sets TOOLTIP_DELAY (500 ms) on its report, mute and show
buttons, so their tooltips appear only after the cursor has rested on the
same button for half a second (WidgetTooltipHolder). Horizon's social screen
drew the row button's tooltip on the first hovered frame.

social_screen::TooltipDelay now tracks the hovered Tip and its start time and
answers whether the tooltip shows; the screen's draw passes the current tip.
2026-11-09 16:21:43 +00:00
MSedgeMC 678d6da047 The book signing screen reopens with the title it kept
BookSignScreen's constructor sets its title box to titleValue, the text saved
when Cancel left the screen. Horizon reopened the sign screen with an empty
box while its title field still held the old title, so the next keystroke
started from nothing and Enter signed a title the box did not show.

BookScreen::begin_signing now fills the title box from the kept title, as the
constructor does, and both ways into the sign screen use it.
2026-11-09 16:21:35 +00:00
Evrim CicekandClaude Opus 5.5 24c9ad202b Move the spawn egg functions out of creatures.rs
A pure move, for the file-size rule: spawn_egg_offspring,
spawn_egg_fluid_hit, spawn_egg_allowed, use_spawn_egg and
apply_spawn_egg_entity_data go to game/creatures/spawn_eggs.rs. Only
visibility and the `super::` paths change. creatures.rs goes from 8896
counted lines to 8732.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:11 +03: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 6020c75004 Pages: a registry for the settings pages crates add
horizon_hooks::pages (re-exported as horizon_platform::pages): a crate
above the engine registers a Page (rows: heading, note, toggle, cycle,
button, key) with its entry on Horizon Settings. The options screen draws
its rows with its own widgets, asks for them only while the page is open,
and hands presses, Shift-presses and bound keys back to the page. With
nothing registered, Horizon Settings is unchanged.

The Horizon Settings rows move to options_ui_pages.rs with the entries,
so options_ui.rs shrinks (8391 -> 8363 lines).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:16:43 +01:00
Vinícius MoreiraandClaude Opus 5.5 f34a261c77 Polytone: screen_time animation mode, and modes on GUI sprites
An animation with mode screen_time runs over the ticks the open screen has been open,
stretched over time_cycle_duration, and holds its last frame once that has run: measured on
the real mod with a recipe book button of four frames over 200 ticks (the first at 2 s,
the last at 10, 18 and 27 s). The GUI atlas's animated sprites now read the modes too
(time of day, weather, screen time), as the terrain's did; their expression modes still
run on ticks. animate_sprites moves to hud/animate.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:16:28 -03:00
Horizon agentandClaude Opus 5.5 6ee08b4a5a Raycasts: take a state's context rule from its collision flag
The name test (scaffolding, powder_snow) missed the liquid rule, so with
an entity's context a lava block took its cached empty shape, where
collision_shape_with_context gives a strider the lava's stable shape.
Read the registry's contextual flag instead, as collision_shape_with_context
does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:50 +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
Evrim CicekandClaude Opus 5.5 91426ff30e Move the painting and item frame NBT out of entity_nbt.rs
A pure move, for the file-size rule: painting_to_nbt, attached_block_pos,
painting_from_nbt, item_frame_to_nbt and item_frame_from_nbt go to
entity_nbt/hanging.rs, re-exported with `pub use` so callers stay as they
are. entity_nbt.rs goes from 4830 counted lines to 4726.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:13:32 +03:00
Horizon agent 1215645c4e Merge remote-tracking branch 'origin/layers/settings-store' into layers/pages-registry 2026-11-09 17:13:28 +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 dd0f9c629b Mob fluids: look for mod fluids only when some are loaded
A mob in water read every cell of its box a second time each tick (the mod fluid scan),
and each vanilla fluid cell of its current once more (mod_fluid_at), with no content mod
loaded. Both are skipped now: the scan when the table holds no mod fluid, the second
read for a vanilla cell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:12:12 +00:00
Horizon agentandClaude Opus 5.5 1636336f99 Merge hdr-page-no-shortcut (main merged in) into hdr-ui-nits
- graphics.rs: main's hdrFailedStart arm stays; this branch reads hdrUiBrightness as an
  older file's share of Paper White in its own arm.
- graphics_ui_hdr_tests.rs: main's held-HDR tests and this branch's UI Brightness test, both.
- gfx/output_apply.rs (new on main): its UI brightness of 0.6 is now 150 nits under the
  reported paper white of 250.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:11:20 +00:00
Horizon agentandClaude Opus 5.5 50a11a1107 Options: native mod pages in their own module
A pure move out of options_ui.rs (8441 lines, may not grow): the native
mod's page rows, its key wait and its key binding go to
options_ui_pages.rs, a child module, so the page registry can land there
(docs/layers.md, Platform registries).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:11:10 +01:00
Horizon agentandClaude Opus 5.5 ca1ed5fac5 Merge main into hdr-page-no-shortcut
main added Graphics::hdr_failed_start beside hdr_key: the field, its default, its
parse arm and the round-trip test keep it, without the shortcut key this branch removes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:10:08 +00:00
Ludovic Marie 7eb057384c Merge pull request #13788 from RINNECODER/up11/ui-pack-glyphs
🤖 Agent: layout now collects a Vec per label even for measure, one small allocation per text per frame; a two-pass over unformatted() would avoid it if a menu ever shows up in a profile.
2026-11-09 17:09:40 +01:00
Ludovic Marie 9976e2053c Merge pull request #13869 from MSedgeMC/fix/inventory-creative-swap
🤖 Agent: the switch to the creative screen was in set_game_mode, which runs on the server thread; the window changes mode through the game event (set_mode_id), so it never saw it. I moved the call into set_mode (pushed 470f9d0), which both paths reach. The way back (CreativeModeInventoryScreen.containerTick) is still a gap. Also, the PR body is the literal text @/tmp/hudbodies/b-inv2.md: the body file never got read.
2026-11-09 17:09:36 +01:00
Ludovic Marie 1875af1a00 Merge pull request #13786 from RINNECODER/up11/sulfur-cube-fields
🤖 Agent: checked SulfurCube's <clinit> in the 26.3 jar: MAX_FUSE (INT) is defined before FROM_BUCKET (BOOLEAN), so 19/20 and the -1 default are right.
2026-11-09 17:09:32 +01:00
Ludovic Marie b9fc2c5539 Merge pull request #13779 from vnsmoreira/packs/polytone-creative-tabs
🤖 Agent: a name given as {translate:...} is shown as the raw key (component_text keeps the key, creative_tab_title never translates it); small follow-up. tag_match scans exported_search per item, fine since it runs only at rebuild.
2026-11-09 17:09:28 +01:00
Ludovic Marie cee8d447f8 Merge pull request #13777 from MSedgeMC/fix/pufferfish-spectator
🤖 Agent: nothing found; the spectator gate sits before the box test, as forNonCombat's canBeSeenByAnyone does.
2026-11-09 17:09:23 +01:00
Ludovic Marie 04880cebe6 Merge pull request #13776 from NikitaMGrimm/fix/timeout-seconds-every-run
🤖 Agent: only test_friends.py (600 s) and test_native_client.py (60 s, frame-limited) pass --timeout-seconds today, so no other harness changes behaviour.
2026-11-09 17:09:18 +01:00
Ludovic Marie c3f0df9033 Merge pull request #13774 from MSedgeMC/fix/enderman-spectator-stare
🤖 Agent: nothing found; can_continue gets the same spectator gate, which forCombat's canBeSeenByAnyone also applies on every re-test.
2026-11-09 17:09:15 +01:00
Ludovic Marie ad1ec8e4f6 Merge pull request #13772 from Mik00000/perf/structure-locate-vanilla-order
🤖 Agent: checked against ChunkGenerator.findNearestMapStructure's bytecode: ring loop outside, placements inside, return on the first ring with a hit; the concentric result already in best plays vanilla's starting nearestDistance.
2026-11-09 17:09:10 +01:00
Ludovic Marie ff66433c34 Merge pull request #13770 from MSedgeMC/fix/brewing-speed-multiplier
🤖 Agent: nothing found; the shared nbt_value::float matches getFloatOr's NumericTag.asFloat for every numeric tag.
2026-11-09 17:09:06 +01:00
Ludovic Marie 61f922fbd5 Merge pull request #13765 from DUzzL/fix/13669-cloud-custom-particle
🤖 Agent: custom_particle_options decodes more of ParticleTypes.CODEC than /particle's own parser in commands/world_cmds.rs; that command could reuse it later. Block/item/vibration types still fall back to the potion swirl, as the body says.
2026-11-09 17:09:00 +01:00
Horizon agentandClaude Opus 5.5 511e971adc Slider sprites: canChangeValue is the keyboard's too, not only the drag
26.3's AbstractSliderButton sets canChangeValue in setFocused (focused with the
mouse or Tab) and flips it on Enter (keyPressed); getSprite/getHandleSprite read
only that flag. Horizon's can_change_value is that flag for the keyboard, and a
mouse drag focuses the slider with it set. So the sprites take both again:
a Tab-focused slider keeps its plain track and lit handle, an arrow-focused one
lights its track, and a drag no longer lights the track (the bug this PR fixes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:08:57 +00:00
Ludovic Marie 211512aee8 Merge pull request #13763 from TheMrZZ/fix/h282-hopper-pot-insertion-2
🤖 Agent: the pot adapter only ever sits behind hopper_insertion_view, so generic discovery (comparators, pulls) stays untouched; a pot still has no modeled block entity, so hopper extraction from a pot remains a gap for a follow-up. Overlaps #13800 only in is_full/move_in_item signatures.
2026-11-09 17:08:55 +01: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
Ludovic Marie edc5f94957 Merge pull request #13791 from MSedgeMC/port_map/directional-sound
🤖 Agent: confirmed tick_end_flash builds SoundInstance::directional and directional_position adds the 10-block view offset, so the row now cites the real port instead of its caller's caller.
2026-11-09 17:08:50 +01:00
TheDanikReal af593b6559 Fix Belarusian translations 2026-11-09 16:08:47 +00:00
Ludovic Marie 761704ce11 Merge pull request #13790 from MSedgeMC/port_map/block-sync-snap
🤖 Agent: confirmed end_predictions calls block_meets_player then snap_target/snap_player_to (remote.rs:2967-2969, unit-tested at 12726), so the old gap note was wrong.
2026-11-09 17:08:45 +01:00
Ludovic Marie 74d6a69a0e Merge pull request #13789 from MSedgeMC/port_map/sprite-animate
🤖 Agent: checked on main: animate.rs has only SpriteAnimator::animate_with now, so both rows pointed at a function that no longer exists; TextureManager.tick as inline matches its own note.
2026-11-09 17:08:41 +01:00
Horizon agentandClaude Opus 5.5 5edd79feaa Settings store: rustfmt, Cargo.lock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:07:26 +01:00
Horizon agentandClaude Opus 5.5 c5121f0f10 Settings store: crates above the engine keep their own settings lines
horizon_hooks::settings (re-exported as horizon_platform::settings): a Store
names its file (horizon-extras.txt or its own), the keys it owns, and loads
and saves its lines. Settings::load and Settings::save route the lines; a
shared file gets the stores' lines after the engine's, an own file keeps the
lines no store owns. The hooks list and the registries share one Registry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:56 +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
Ludovic Marie c2414d0c5d Merge pull request #13982 from horizon-rust/land/11446
🤖 Agent: lands GabrielNichols' #11446 (fuel alternatives in the Recipes cooking card); its seven JEI sprites go into jei_sprites.rs and the fuel list's wheel shares the bundle's check, so hud.rs and game.rs don't grow.
2026-11-09 17:04:35 +01:00
Ludovic Marie b6b65ccb8d Merge pull request #13796 from MSedgeMC/fix/banner-raid-end
🤖 Agent: checked against Raider.ObtainRaidLeaderBannerGoal.cannotPickUpBanner: active raid, isOver and canBeLeader (witch/ravager excluded) are ported; the ominous-banner-on-head and living-wave-leader terms are still missing, which the body says.
2026-11-09 17:04:31 +01:00
Ludovic Marie ce2000390a Merge pull request #13792 from MSedgeMC/fix/beg-spectator
🤖 Agent: matches 26.3 BegGoal: forNonCombat filters spectators only in canUse, while canContinueToUse checks just isAlive, distance and the held item, so moving !spectator out of interesting() also lets a wolf keep begging at a player who turns spectator, as vanilla does.
2026-11-09 17:04:27 +01:00
Horizon-agentandClaude Opus 5.5 0b5a2c1e4f Let the camel jump harness gate on its non-diagnostic rows only
The corpus declares diag- rows as recorded diagnostics of separate
differences (the /ride mount event, RootVehicle); the PR itself reports 6
of them still differing, so exiting 1 on any difference made the new
harness fail on every automatic parity run. They stay in the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:03:01 +00:00
Horizon agent c587403015 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/game.rs
2026-11-09 17:03:01 +01:00
Horizon agentandClaude Opus 5.5 3ebcf19eed docs/layers.md: spec of the platform registries (settings, pages, HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:00 +01:00
Horizon agentandClaude Opus 5.5 f2f02e57bd Moderation durations move from horizon-chat-filter to horizon-server-files
Layer plan M6, a pure move before the chat filter's engine hook: /tempban (engine) and /tempmute
share parse_duration, duration_parts, duration_text and now_millis, which now live in the engine's
horizon-server-files (duration.rs). horizon-chat-filter re-exports them. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:55 +01:00
Horizon agentandClaude Opus 5.5 18a2d812a7 game.rs doesn't grow: interact_location moves to hand.rs
Its only callers are hand.rs and the core tests (through game.rs's import).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:01 +01:00
Ludovic Marie 0f236dfaeb Merge pull request #13948 from MSedgeMC/fix/editbox-editable
🤖 Agent: matches AnvilScreen: setEditable(!input.isEmpty()) on slotChanged with the box kept focused, and setTextColorUneditable(-1) so it stays white. I changed the anvil test to expect the empty value slotChanged sets when the input goes, instead of a name that stays.
2026-11-09 17:01:31 +01:00
Ludovic Marie 1d56cf6658 Merge pull request #13922 from adiraj66132/perf/region-first-free-sector
🤖 Agent: the watermark stays exact because every write to used goes through force/free; the 120-op test pins placements to first fit from 0 (rustfmt and temp-dir cleanup pushed).
2026-11-09 17:01:27 +01:00
MSedgeMC c10aea9eb8 Equipment attribute modifiers come from the stack
ItemAttributeModifiers is a component, so a patched stack replaces the item's
prototype modifiers. Horizon's mob attack and armour comparisons read
ItemInfo.modifiers, ignoring the stack's own.

Test: a sword with a patched attack_damage +5 reports that modifier.
2026-11-09 16:00:18 +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 bf22beb825 An absent enchantments component makes enchant a no-op
ItemStack.enchant uses EnchantmentHelper.updateEnchantments, which returns
EMPTY when the component is absent. Horizon added the enchantment to the
modelled list anyway, so enchant_randomly and enchant_with_levels enchanted a
stack whose !minecraft:enchantments patch removed the component.

Test: a sword with !minecraft:enchantments stays unenchanted after enchant;
an enchanted book with !minecraft:stored_enchantments does too.
2026-11-09 15:59:43 +00: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
Ludovic Marie 5a99cf8ecf Merge pull request #13983 from horizon-rust/agent/big-file-room
🤖 Agent: a pure move (only `fn` visibility words change; rustfmt rewrapped one signature): ProjWorld's ProjectileWorld impl, the dragon's death render, the minecart display helpers and StopDig leave four files over 3000 lines. That gives #11507 room under the size rule. Verify and launcher are green on this head.
2026-11-09 16:58:37 +01:00
Horizon agentandClaude Opus 5.5 510bb6f135 Hub::synced_name lives in lan/registry_names.rs
hub.rs is over 3000 lines and may not grow (tools/check_file_size.py); the
method moves unchanged to a file of its own beside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:57:46 +01:00
Horizon agentandClaude Opus 5.5 10fb981317 Revert #13968: pack-format types back out of the engine
horizon-render-pipeline is an engine crate, and the config it held is the
shaders/ format's buffer and shadow configuration (colortex buffers, Iris
half-lives, shaders.properties settings). docs/layers.md: the engine never
holds a shader or pack format vanilla doesn't read; pack formats and the
shader API are platform. The M6 shader-pack move restarts from a spec.

This reverts merge 75b85d258f.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:57:43 +01:00
Ludovic Marie 7160eea6d5 Merge pull request #13844 from NikitaMGrimm/hdr-pass-rules
🤖 Agent: docs only; checked the references against main: rule 2's `2.0 - hi` limit is world_post.wgsl:77, TAA's history is Rgba16Float (world_post.rs HISTORY_FORMAT), and Hdr::tonemap / Hdr::sdr_screenshot exist as named.
2026-11-09 16:56:15 +01:00
Evrim CicekandClaude Opus 5.5 443a42f97a Move the minecart's save/load and cart index out of minecarts.rs
A pure move, for the file-size rule: `impl Minecart`'s to_nbt/load go to
game/minecarts/nbt.rs, `CartIndex` and `cart_at` to game/minecarts/index.rs.
Only visibility changes so minecarts.rs still reaches them. minecarts.rs
goes from 3230 counted lines to 2967.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:04 +03:00
SenjinandClaude Opus 5.5 d84e8fa460 Shader packs: a varying the fragment stage never reads takes no location
The translator gave every vertex output a location, read or not. Under GL the linker drops an
inactive varying, so packs declare more than they pass on: a deferred pack's shadow program
writes 30 vertex outputs and reads 17, which left no room on a GPU with 31 inter-stage locations.

A pack's varying its fragment stage never names now takes no location; a vertex output stays a
global of the vertex stage. Ours (the built-ins, the boats' patch place) always travel. The
assignment moves to translate/varyings.rs (mod.rs is over the file-size limit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:55:45 +01:00
Nikita Maximilian Grimm 098efef8e2 test_git_slim: the bash on PATH, and no automatic maintenance in its repositories
A bare "bash" is WSL's on Windows, and git 2.54's automatic maintenance
after a commit folds the packs the tests build, so the suite failed on
Windows (and would with a newer git anywhere).
2026-11-09 16:55:01 +01: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 daf537e0d4 Merge main into split-block-particles
game.rs: main moved the neighbouring item and hand helpers out (#13856,
#13877); kept main's file and removed only crack_particle and
particle_sprite, as this branch does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:54:33 +01:00
Ludovic Marie 75b85d258f Merge pull request #13968 from horizon-rust/split/render-pipeline-config
🤖 Agent: config types move to the new engine crate horizon-render-pipeline; horizon-shaders keeps the parsing as the ReadConfig extension trait, so no pack reads differently.
2026-11-09 16:54:02 +01:00
Ludovic Marie fe2982ecdd Merge pull request #13977 from horizon-rust/move/jei-sprites
🤖 Agent: pure move, same six JEI sprites in the same order now come from jei_sprites::SPRITES; hud.rs shrinks by 26 lines, which the Recipes PRs need under the file-size rule.
2026-11-09 16:53:58 +01:00
TheMrZZandCodex gpt-6.1-sol 7f67dc8333 fix(lan): answer play ping requests on the origin connection
Keep PingRequest through held/handover filtering, handle it in Hub transport,
and exclude it from action-time updates. Preserve the existing reverse Pong path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-09 16:53:54 +01:00
Horizon agentandClaude Opus 5.5 260051a190 Lockfiles after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:29 +01: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
Horizon agent 66dafd2bb6 Merge remote-tracking branch 'origin/main' into split/launcher-server-platform
# Conflicts:
#	Cargo.lock
#	crates/horizon-platform/Cargo.toml
#	crates/horizon-platform/src/lib.rs
#	launcher/Cargo.lock
2026-11-09 16:51:12 +01:00
Ludovic Marie 5248e83f28 Merge pull request #13953 from DUzzL/fix/13852-entity-ticking-neighbours
🤖 Agent: the 2-column gate matches ChunkMap.prepareEntityTickingChunk (getChunkRangeFuture radius 2), but it ran 25 hash probes per entity per call site; pushed a per-chunk memo inside the closure so a crowd in one chunk pays the neighbourhood check once. Forced chunks stream 3-4 rings, so a /forceload'd chunk still gets its ring.
2026-11-09 16:51:04 +01:00
Horizon-agentandClaude Opus 5.5 ada4756065 Keep lan.rs and lan_actions.rs from growing under the current size check
main's check_file_size no longer counts test modules, so moving the two
inline test modules out no longer offset the new sea_level lines; it added
#[path] lines instead. The tests are back inline, and two self-contained
functions move to child modules unchanged (pure moves):
level_event_except to lan/level_event_targets.rs and horizon_channel to
lan_actions/horizon_channels.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:50:27 +00:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
Horizon-agentandClaude Opus 5.5 2fe3429a9a Insert a disc by its own jukebox_playable component too
JukeboxBlock.useItemOn asks the stack for JUKEBOX_PLAYABLE, so the insert
path and the client-side jukebox_takes now read the stack the same way the
comparator and the song tick do. An inline song definition is playable but
has no id to play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:49:37 +00:00
Horizon agentandClaude Opus 5.5 3db7aa7cac Fix the fields' renames in the moved emote files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:21 +01: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
Horizon agentandClaude Opus 5.5 5e6181b8f3 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:58 +01:00
Ludovic Marie 93ecc8d0c1 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:46:39 +01:00
Horizon agentandClaude Opus 5.5 230b167744 Keep PackColors::entity_shadows a method, as the shadow ownership check reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:36 +01:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
pehe1andCodex 08f7f87f23 Use the item crate for the fixture save round trip
Co-Authored-By: Codex <[email protected]>
2026-11-09 12:46:16 -03: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
Horizon-agent 5b4984aa32 Merge remote-tracking branch 'origin/main' into m13723
# Conflicts:
#	crates/horizon-client/src/container_screen.rs
2026-11-09 15:45:28 +00:00
Ludovic Marie 64d3619671 Merge pull request #13875 from TheMrZZ/ci/nightly-steps-bash32
🤖 Agent: ${arr[@]+"${arr[@]}"} expands to nothing for an empty array on bash 3.2 and to the same quoted words otherwise, so the nightly's argument lists are unchanged on CI's bash.
2026-11-09 16:45:15 +01:00
Ludovic Marie 693c5326f1 Merge pull request #13879 from NikitaMGrimm/hdr-calibration-window
🤖 Agent: the hint's font.split runs only while the calibration page is open, so no per-frame cost elsewhere; set_maximized right after set_fullscreen(None) is the one Windows-only step CI can't exercise.
2026-11-09 16:45:10 +01:00
Ludovic Marie 145d9e593b Merge pull request #13862 from vnsmoreira/fix/death-time-short
🤖 Agent: (short) casts with no clamp match LivingEntity.addAdditionalSaveData; a death_time past 32767 now wraps like Java instead of saturating. Nothing else found.
2026-11-09 16:45:04 +01:00
Horizon-agentandClaude Opus 5.5 8396106b2e Stats screen: look item registry ids up once per sort, not per row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:45:01 +00:00
Ludovic Marie f000ba2db4 Merge pull request #13861 from MSedgeMC/fix/advancements-seen-tab
🤖 Agent: both packets match ClientAdvancements.setSelectedTab (sent before the same-tab compare) and AdvancementsScreen.removed. The client still ignores ClientboundSelectAdvancementsTabPacket, so the server's answer doesn't reopen its tab yet; that's a separate follow-up.
2026-11-09 16:44:59 +01:00
Ludovic Marie 720146f962 Merge pull request #13850 from TheMrZZ/audit/block-predicate-typed-state-ranges
🤖 Agent: matches RangedMatcher.match (Property.getValue on each bound, then compareTo); Direction ranks by its ordinal, not the facing property's possible-values order, which is the case a domain-index rule gets wrong. block_property_values allocates the domain per check, fine since only object ranges reach it. The blank lines removed to stay under the 3000-line cap are cosmetic.
2026-11-09 16:44:54 +01:00
Ludovic Marie bce20797b6 Merge pull request #10243 from iwaneth/codex/piston-projectile-parity
🤖 Agent: a stuck arrow or trident now leaves its block from the piston's own move (AbstractArrow.move with a non-SELF mover and shouldFall), with startFalling's three nextFloat()*0.2 draws and life = 0, instead of on any client block change; tick order stays Minecraft.tick's.
2026-11-09 16:44:48 +01:00
Ludovic Marie 6ea90b860a Merge pull request #11083 from voidsipp/fix/10914-native-content-assets
🤖 Agent: a combined native .hmod's content is validated through Content::read (no ../ or foreign namespaces), hashed into the build key and snapshotted beside that exact build, so an updated archive can't change an older build's sounds; the out-of-scope ci.yml/check_file_size change was taken out.
2026-11-09 16:44:44 +01:00
RLC248 d1d212ef32 Merge remote-tracking branch 'upstream/main' into fix/anger-end-time-roundtrip 2026-11-09 16:44:39 +01:00
Ludovic Marie 0533979cc8 Merge pull request #13859 from DylanGonzaloFerreyra/split-play-commands
🤖 Agent: the command-tree codecs move verbatim and play.rs re-exports ARGUMENT_TYPES/ArgumentProperties/CommandNode/Commands, so horizon_protocol::packets::play paths keep working; nothing found.
2026-11-09 16:44:39 +01:00
RLC248 f6eff6b43a A neutral mob keeps the anger end time it loaded
NeutralMob.addPersistentAngerSaveData writes anger_end_time as
readPersistentAngerSaveData read it; only startPersistentAngerTimer and
stopBeingAngry change it. Horizon re-stamped it from the countdown on
every read, so a past end read back as -1 and a mob that missed ticks
moved its end forward.

The end time now stays as loaded while the countdown agrees with it or
nothing changed it, and the stopBeingAngry sites write -1 (#13076).
2026-11-09 16:44:38 +01:00
Horizon agent 7abf696d29 Merge split/cosmetics-prep
# Conflicts:
#	crates/horizon-client/src/game/cosmetics/emotes.rs
2026-11-09 16:44:34 +01:00
Ludovic Marie e766d161d3 Merge pull request #13880 from Skr1zzzz/split-block-breaking
🤖 Agent: pure move of break_block and its four helpers, bodies identical, port_map rows repointed; nothing found.
2026-11-09 16:44:34 +01:00
Ludovic Marie 62f7d0fc17 Merge pull request #13878 from Skr1zzzz/split-explosions
🤖 Agent: pure move; the only change is the explosion:: paths shortening to the module's own BlockInteraction/ExplosionSource, which name the same types. Nothing found.
2026-11-09 16:44:29 +01:00
Ludovic Marie 74da693e68 Merge pull request #13877 from Skr1zzzz/split-held-items
🤖 Agent: pure move of the seven hand/inventory helpers (held, with_hand, give, glider_worn...), bodies identical; nothing found.
2026-11-09 16:44:24 +01:00
Ludovic Marie 311a64ea13 Merge pull request #13860 from Skr1zzzz/split-inventory-slots
🤖 Agent: pure move of container_kind/slot_count/slots, identical bodies; nothing found.
2026-11-09 16:44:20 +01:00
Ludovic Marie 3b53c55932 Merge pull request #13856 from Skr1zzzz/split-item-entities
🤖 Agent: pure move: spawn_item, the pop_resource pair, drops and block_experience match game.rs line for line (only rustfmt wrapping and pub(super) differ). Heads-up: #13864 touches the lines next to these and will need main merged in after this lands.
2026-11-09 16:44:16 +01:00
Ludovic Marie f9dee224bc Merge pull request #13854 from Skr1zzzz/split-weather
🤖 Agent: pure move checked line by line: the 8 weather/sky fns leave game.rs unchanged apart from pub(super); nothing found.
2026-11-09 16:44:11 +01:00
Horizon agentandClaude Opus 5.5 a94ef019be rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:25 +01:00
pehe1andCodex 1c9ebb2318 Merge current Horizon main into blocking durability
Co-Authored-By: Codex <[email protected]>
2026-11-09 12:43:20 -03:00
MSedgeMC e64dc6ad91 Keep one import of the goals module in the moved test file 2026-11-09 15:42:37 +00: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
Mik00000 3c710450fb mobs: optimize spatial SeenGrid candidate retrieval and others_near allocations 2026-11-09 17:40:49 +02:00
Horizon agentandClaude Opus 5.5 50295a160c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:17 +01:00
Ludovic Marie 79e396c135 Merge pull request #13679 from d3xc0/android-touch-3b
🤖 Agent: nothing calls the gestures yet, so this is relabelled skip-release; one point for part 4's wiring: poll_state presses triggers[0] for a hold that uses the item and triggers[1] for mining, the same slots the shield button uses, so a shield hold and an eating hold on two fingers both drive triggers[0].
2026-11-09 16:40:05 +01:00
Ludovic Marie 40c22423dd Merge pull request #13952 from horizon-rust/layers/replay-packet-tap
🤖 Agent: net.rs now names only the PacketRecorder trait; the privacy filter moved with its tests to studio/recorder.rs, and verify passed (workspace tests + server build).
2026-11-09 16:40:01 +01:00
Horizon agentandClaude Opus 5.5 308bf3dae0 test: the overlay-hold check no longer races the clock
50 one-millisecond sleeps could pass the 80 ms hold on a loaded runner and
fail the test; hold for an hour, wait for the worker, then lift the hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:51 +01:00
MrCloudy2andClaude Opus 5.5 6a6aa75cb9 A stuck arrow falls after every non-self move, not only a push that moved it
AbstractArrow.move checks shouldFall after every Entity.move with a mover
other than the arrow, including a push limitPistonMovement or the collisions
cut to nothing. #10243's release ran only in apply_push; it is now
release_after_block_move, called after every push.
Harness: tools/test_piston_arrow_fall_parity.py; unit test
a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:01 +01:00
Horizon agentandClaude Opus 5.5 470f9d01e0 fix: the window's inventory follows a mode change too
set_game_mode runs on the server thread; the window changes mode through
the server's game event (set_mode_id), so the switch to the creative
screen now lives in set_mode, which both reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:38:19 +01:00
Horizon agentandClaude Opus 5.5 8559f9ccd2 Merge origin/main: keep both the first-release and the double-click tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:25 +01:00
Ludovic Marie ae259da5df Merge pull request #13785 from MSedgeMC/port_map/sheep-wool-colour
🤖 Agent: nothing found; render.rs::layers picks jeb_wool_rgb(age) for jeb_ sheep on main, as the row now says.
2026-11-09 16:36:24 +01:00
Ludovic Marie f0ffcadaf5 Merge pull request #13784 from MSedgeMC/port_map/sheep-colour-layers
🤖 Agent: nothing found; glow_squid_block_light is called from creatures.rs mob_geometry on main, so dropping the 'never brightens' gap is right.
2026-11-09 16:36:20 +01:00
Ludovic Marie 771705a060 Merge pull request #13782 from MSedgeMC/port_map/frame-geometry-note
🤖 Agent: nothing found; checked that celestial_geometry is at game/frame_geometry.rs:79 on main.
2026-11-09 16:36:16 +01:00
Evrim CicekandClaude Opus 5.5 9b72080c32 Share the fall-reset ray between mobs and vehicles, water first
Vanilla's clip queries the block shape and the fluid shape of every
block it visits. The vehicle ray skipped the water on a tagged block, so
a waterlogged ladder or vine never fixed the shared water shape there as
vanilla's first query does; the mob, vehicle and item rays now share one
helper that queries the water first, as the player's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:36:15 +03:00
Ludovic Marie 86255fd295 Merge pull request #13949 from horizon-rust/layers/seam-official
🤖 Agent: the shipped binary is now horizon-official, copied to horizon-client before signing and packaging. The Windows icon and 8 MB stack come from the same build-script code (packaging/windows/exe.rs). The launcher's native builds pick it only when the revision has crates/horizon-official, and the server build stays horizon-client's. The ping moves out of the engine.
2026-11-09 16:36:12 +01:00
Vinícius MoreiraandClaude Opus 5.5 ed3fabb7b4 command_exec: the command block case keeps to failed commands
A success's feedback names the player with an entity hover, a new score
holder (which changes a later scoreboard players list) or a bracketed
value, whose components the two engines build differently. The case
keeps the two failures, whose styled feedback is what the save must keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:35:40 -03:00
Horizon agentandClaude Opus 5.5 c93db2be5f Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:02 +01:00
Horizon agentandClaude Opus 5.5 e1cd3420ce Take the pushed projectile's bubble surfaces instead of draining them
clippy's drain_collect: the drained Vec is the same type as the new one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:34:58 +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
MSedgeMC 83c50cf720 A sign whose messages are not a list loads an empty side
SignText.CODEC reads a side's messages as a list; any other tag fails the side
and leaves SignText.EMPTY. Horizon ignored the bad field but still applied the
side's colour and glow.
2026-11-09 15:32:32 +00: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
Horizon agentandClaude Opus 5.5 21d6f5610b A partial pickup still takes the item and awards its stat
Inventory.add returns true once any of the stack went in (count < the
count before), so ItemEntity.playerTouch's take, ITEM_PICKED_UP stat and
onItemPickup run on a partial pickup too. The stat counts the stack's
count before the add, as playerTouch's local count does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:32:03 +01:00
Ludovic Marie 8321f11360 Merge pull request #13928 from MSedgeMC/port_map/minecart-hurt-tilt
🤖 Agent: confirmed on main against AbstractMinecartRenderer.submit: the offsetSeed jitter (×0.004 per axis) and the sin(hurt)·hurt·damage/10·hurtDir roll with partial-tick hurt and damage are both in cart_geometry.
2026-11-09 16:31:37 +01:00
Ludovic Marie 4c231b6c45 Merge pull request #13927 from MSedgeMC/port_map/enchant-hand-slots
🤖 Agent: confirmed on main: apply_enchantment_attributes walks the armour, then mainhand and offhand (enchant_effects.rs:1375-1381), each gated by equipment_modifier_applies.
2026-11-09 16:31:32 +01:00
MSedgeMC df6c3384fb A container's loot seed is read as any number
RandomizableContainer.tryLoadLootTable reads LootTableSeed with getLongOr,
which takes any numeric tag. Horizon's loot_seed matched only a Long tag and
rolled an unseeded container from its position instead.
2026-11-09 15:31:30 +00:00
Ludovic Marie a7934f85c2 Merge pull request #13926 from MSedgeMC/port_map/minecart-display-nbt
🤖 Agent: confirmed on main: to_nbt writes DisplayOffset only off the kind's default and DisplayState only when set (minecarts.rs:3066-3083), and forget_unreadable_display drops a state that no longer resolves.
2026-11-09 16:31:28 +01:00
Ludovic Marie 05bc440a21 Merge pull request #13924 from MSedgeMC/port_map/hoglin-nbt
🤖 Agent: confirmed on main: mob_to_nbt writes CannotBeHunted (entity_nbt.rs:1026), mob_from_nbt reads it (2259), and a_hoglin_keeps_cannot_be_hunted_and_is_no_prey round-trips it; the gap notes were stale.
2026-11-09 16:31:24 +01:00
Ludovic Marie fffb893691 Merge pull request #13915 from Knifethrower/perf/box-template-block-nbt
🤖 Agent: boxing takes BlockInfo from 40 to 24 bytes; the only added cost is one allocation per NBT-carrying block at template load, and place_in_world moves the box out (*nbt) with no clone. Main has no new BlockInfo construction since your base, and horizon-world --all-targets checks clean merged with main.
2026-11-09 16:31:19 +01:00
Ludovic Marie 1dfd9f03a6 Merge pull request #13909 from Skr1zzzz/split-equipment
🤖 Agent: pure move, removed and added lines match; apply_equipment now reaches equipment_modifier_applies through the named import, and all its callers sit inside game/, so pub(super) from player_equip.rs still reaches them.
2026-11-09 16:31:14 +01:00
Ludovic Marie 5bec6ad6ee Merge pull request #12569 from gigamans442/fix/glsl-signed-indices-20261108
🤖 Agent: gl_VertexID/gl_InstanceID become int(gl_VertexIndex)/int(gl_InstanceIndex) at the token, so (gl_VertexID - 7) / 2 divides signed as GLSL says; explicit uint() casts keep their meaning.
2026-11-09 16:28:18 +01:00
Horizon agentandClaude Opus 5.5 047ec7683e Anvil rename test: an emptied input empties the box
AnvilScreen.slotChanged calls setValue("") when the input slot empties;
the caller passes the empty name, so the test does too instead of pinning
a name that stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:16 +01:00
Ludovic Marie 0be74d73d6 Merge pull request #12926 from Teiwazik/upscaler/backend-seam
🤖 Agent: TAA's uniform is rebuilt from UpscaleFrame with jitter[3] = 0 and reset as 1.0/0.0, the same bytes as taa_this_frame, so the move is exact. skip-release added.
2026-11-09 16:28:15 +01:00
Ludovic Marie edddfd6008 Merge pull request #13623 from NikitaMGrimm/hdr-output-o2
🤖 Agent: the display watch, and with it the get_capabilities read every 2 s, exists only while the HDR mode isn't Off, so players with HDR off pay nothing. Lands #13555's commits with it if that one isn't in yet.
2026-11-09 16:28:10 +01:00
Ludovic Marie fbc2031786 Merge pull request #13555 from NikitaMGrimm/hdr-crash-recovery
🤖 Agent: the hold is keyed on the driver fingerprint, OS version included, so a Windows cumulative update retries HDR every month by design; HDR off only drops the marker as before. Spec #13661 approved.
2026-11-09 16:28:06 +01:00
Horizon agentandClaude Opus 5.5 2e45a0750d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:26 +01:00
Horizon agent 8ad2c758ad Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:27:26 +01:00
Horizon agentandClaude Opus 5.5 c71978372d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:24 +01:00
Horizon agentandClaude Opus 5.5 497c137523 Move four self-contained pieces out of creatures.rs, dragon.rs, minecarts.rs and lan_actions.rs
A pure move, so fixes in these files fit under the file-size rule (docs/layers.md#file-size):
- creatures.rs: ProjWorld's ProjectileWorld impl -> creatures/projectile_world.rs
- dragon.rs: the death dissolve and rays -> dragon/death_render.rs
- minecarts.rs: the display-offset helpers -> minecarts/display.rs
- lan_actions.rs: StopDig and stop_dig -> lan_actions/dig_stop.rs
Only visibility changes (private -> pub(super), pub(super) -> pub(in super::super)) and the
port map row for EnderDragonRenderer.submitRays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:00 +01:00
Horizon agentandClaude Opus 5.5 0660817c30 Registry review: a version taken down by hash may leave its entry
A project whose signing key was lost (horizon-mods#276, baritone) can't keep
its old versions listed: the index checks every version against the entry's
one key. The review refused removing them even after revoked.toml took their
files down by sha256. Now such a removal is a note, not an error; a version
removed without a take-down by hash is still refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:26:54 +01:00
Horizon agentandClaude Opus 5.5 16341f4d04 Land #11446 on the moved JEI sprite list
Merge move/jei-sprites (#13977): this PR's seven sprites go into jei_sprites.rs instead of hud.rs. The fuel list's wheel joins the bundle's in game.rs's container scroll (both take the wheel before the screen's own scrolling), so neither big file grows (docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:26:13 +01:00
Appa 50901bae92 Reuse isolated game environment in DLL startup regression
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit f047bd7949)
2026-11-09 16:25:06 +01:00
Appa c23c0056a1 Match the regression fixture to actual launcher DLL imports
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit f36aff7aea)
2026-11-09 16:25:06 +01:00
Appa 76c3d4d262 Fix Windows launcher startup beside injector DLLs
Resolve static launcher imports from System32 before program entry and
exercise launcher/setup startup beside unrelated DLLs in release checks.

Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 6d913502bf)
2026-11-09 16:25:06 +01:00
Horizon agentandClaude Opus 5.5 c024816c37 Merge main into blocking durability; the dispenser game keeps lab.game_env
Main's own tool cases joined the case list beside the blocking ones. The
branch had replaced lab.game_env with os.environ for the game it starts,
which test_game_home (every script that starts the game uses game_env)
refuses: the game then wrote into the real HORIZON_HOME. --game-dir and
the .exe name on Windows stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:50 +01:00
Horizon agentandClaude Opus 5.5 987dc976d3 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:30 +01:00
Horizon agentandClaude Opus 5.5 c9246a4fc5 Merge main into #12175
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:21 +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 agentandClaude Opus 5.5 e3451f9fe6 Move the Recipes viewer's JEI sprite list out of hud.rs
Pure move: the six (name, PNG) pairs HudAtlas loads as horizon/jei/<name> now live in jei_sprites.rs. hud.rs is over 3000 lines and may not grow (docs/layers.md#file-size), and the open Recipes PRs (#11446, #11448, #11449, #11453) each add sprites to this list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:13 +01:00
DUzzL 282992c1ec Fix rider item pickup area and partial pickup side effects 2026-11-09 16:24:05 +01:00
MSedgeMC 21b1ebd511 The read-only box consumes Backspace, as EditBox.keyPressed returns true
A refused Backspace still returns true, so the anvil test expects the key to
be handled with the value unchanged.
2026-11-09 15:23:25 +00:00
Horizon agent eee2cd0db1 Merge main into native_registry_name 2026-11-09 16:23:08 +01:00
Horizon agentandClaude Opus 5.5 448ba30415 horizon-prepare: prefer a launcher folder that has every library
A launcher folder with 26.3's version JSON and jar but a missing library
(an official launcher that never finished downloading) was picked over a
complete copy in another launcher (the CurseForge app), and preparing
failed on the missing jar (#13872). game_files now tries folders with every
client library first, then the old order, so the error still names the
library when no folder has them all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:22:59 +01:00
Horizon agentandClaude Opus 5.5 b82eeb4f95 Merge origin/main into #11446
Union of both sides: main's JEI notice (it already names the sprites), main's button/search sprites and settings button beside this PR's fuel sprites, tooltip and popup; en_us.json keeps main's keys and adds the three fuel strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:22:29 +01:00
Horizon agentandClaude Opus 5.5 8cd25efc36 Entity ticking gate: one lookup per entity, not 25
Every caller asks once per entity, so remember each chunk's 5x5 answer
inside the closure: a crowd of entities in one chunk pays the neighbourhood
probe once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:20:45 +01:00
Ludovic Marie b80251563e Merge pull request #13947 from MSedgeMC/fix/switcher-mouse-release
🤖 Agent: matches GameModeSwitcherScreen.mouseReleased (keyDebugModifier.matchesMouse, then switchToHoveredGameMode and setScreen(null)); comparing the binding's key name and requiring key.mouse.* is the same type check matchesMouse makes. Nothing else found.
2026-11-09 16:20:02 +01:00
Ludovic Marie 6aafaf66cd Merge pull request #13946 from MSedgeMC/fix/container-double-click
🤖 Agent: checked against 26.3's bytecode: mouseClicked stores lastClickSlot at its very end for every click that gets past Screen.mouseClicked, null slot included, so recording before the outside/throw branch is right; the recipe book and native-screen presses still return before it, as the widgets do in vanilla.
2026-11-09 16:19:58 +01:00
Ludovic Marie f4b5bee9e5 Merge pull request #13944 from Ragbie/fix/guest-horse-fall-clip
🤖 Agent: moving the reset before the moved-wrongly check is the real fix here: handleMoveVehicle runs vehicle.move (and its FALLDAMAGE_RESETTING clip) before it can put the move back, so a rejected move keeps the reset for the boat too. The two horse cases are --ignored (need resources), so CI proves this through the 'vehicle fall clip' harness group.
2026-11-09 16:19:54 +01:00
Horizon agentandClaude Opus 5.5 4bdd1282b2 rustfmt the sync-flag lines in lan.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:15 +01:00
Horizon agentandClaude Opus 5.5 09722f6830 Merge origin/main into #10243
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:02 +01:00
Horizon agentandClaude Opus 5.5 86b52f39ec lan.rs is over 3000 lines and may not grow: clear the projectile and trident sync flags in one line each
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:00 +01:00
Ludovic Marie 4f311197bd Merge split/cosmetics-prep 2026-11-09 16:18:58 +01:00
Horizon agentandClaude Opus 5.5 4ffc44a599 region: rustfmt the first-free test, remove its temp dir
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:54 +01:00
Horizon agentandClaude Opus 5.5 d8483e0972 Merge origin/main into #10243
main gave tridents AbstractArrow.shouldFall/startFalling (server side) and pistons set_piston_on_ground/set_on_ground: kept beside this PR's piston release. The trident keeps main's startFalling, which now also does vanilla's life = 0 and marks the LAN sync.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:39 +01:00
Evrim CicekandClaude Opus 5.5 6187796e59 A mob resets its fall on a move of a block or more through a vine or water
Entity.move casts a ray along any move of a block or more
(ClipContext.Block.FALLDAMAGE_RESETTING, a tagged block as a full block;
ClipContext.Fluid.WATER) and a hit resets the fall before the move's
drop counts. Mob::do_move never cast it, so a mob falling fast past a
vine kept its whole fall and landed hurt (or dead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:16 +03:00
Horizon agent 49c2e8c55f Merge remote-tracking branch 'origin/main' into split/cosmetics-prep
# Conflicts:
#	Cargo.lock
#	tools/layers.json
2026-11-09 16:18:08 +01:00
MrCloudy2andClaude Opus 5.5 447514aa1d Harness: a piston's push gives a projectile its bubble column's push
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:17:55 +01:00
Ludovic Marie 8c0ec0945d Merge pull request #13919 from horizon-rust/layers/seam-hooks
🤖 Agent: the seam's foundation. The engine gets horizon-hooks (Hooks trait, register before the run, sealed in entry::main; an empty dispatch is one OnceLock load). The ping now runs only through it, still registered by horizon-client until #13949 ships horizon-official. No allowed entry added.
2026-11-09 16:17:03 +01:00
MSedgeMC 5ed5756b1f The read-only test clears its selection before typing again
insert_text replaces the selection, so the editable half of the test starts
with the caret at the end.
2026-11-09 15:15:53 +00: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 3df9ec4110 Merge origin/main into #11083; leave the file-size CI change for its own PR
Conflict in game/mods.rs: main reads enabled packages in parallel (read_contents); this PR's rules (a native-content snapshot is on unless the mod is disabled, a legacy copy never replaces the snapshot) now live in that filter. The ci.yml/check_file_size.py --pr-head change is a CI change in a feature PR and is not needed for it: main's versions are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:48 +01:00
MSedgeMC 0490e076ff The anvil's redraw uses the moved limit constant
draw_anvil_name still named ANVIL_NAME_CHARS, which now lives in the anvil
module.
2026-11-09 15:13:57 +00:00
MSedgeMC c5897ab467 The first-release test carries a stack, the hand that would click
A release with an empty hand throws nothing whether skipNextRelease is set or
not; only a carried stack makes mouseReleased click, which is the hand that
opens a chest while holding one.
2026-11-09 15:13:54 +00:00
Ludovic Marie 6442dc599e Merge pull request #13932 from horizon-rust/ci/git-slim
🤖 Agent: git-slim only touches .git packs and tmp_pack files, never refs or the working tree; 5 tests cover fold, gc, tmp cleanup.
2026-11-09 16:13:36 +01:00
Ludovic Marie 0ed8b278ad Merge pull request #10088 from MSedgeMC/fix/subtitles-over-screens
🤖 Agent: Screen.isInGameUi screens (containers, block editors, book, sign) now keep the subtitles under F1 and draw them before the screen, as Screen.extractBackground runs Hud.extractDeferredSubtitles; the subtitle and HUD-render gates moved out of game.rs, which stays under its size.
2026-11-09 16:13:32 +01:00
Horizon agentandClaude Opus 5.5 4639551d77 rustfmt recorder.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:12:57 +01:00
Horizon agentandClaude Opus 5.5 0f70c0c356 launcher: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:12:12 +01:00
Ludovic Marie a574df5379 Merge pull request #11011 from rison1337/perf/cull-offscreen-xp-orbs
🤖 Agent: the orb cull runs only behind the camera (forward·c < 0) before the six-plane sphere test, so visible orbs pay one dot product; @rdbtCVS's split is done and approved at this head.
2026-11-09 16:10:34 +01:00
Ludovic Marie 2e4c2b511f Merge pull request #11803 from horizon-rust/ci/runner-gc-busy-versioned-bin
🤖 Agent: the new `-[a-z0-9_]{8}$` rule matches `tempfile.mkdtemp`'s suffix alphabet. It sits after the symlink and `parity-runner` skips, so the shared preparations and runner links are never touched. Verify is green on this head.
2026-11-09 16:10:29 +01:00
Horizon agentandClaude Opus 5.5 70db97e662 Merge origin/main into #8230
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:09:45 +01:00
MrCloudy2andClaude Opus 5.5 d87da7928c A piston's push applies a projectile's bubble columns too
moveEntityByPiston's applyEffectsFromBlocks: a projectile a piston pushed
through a bubble column gets Projectile.onInsideBubbleColumn /
onAboveBubbleColumn, as after its own move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:09:19 +01:00
Horizon agentandClaude Opus 5.5 481afc2e7b Cosmetics in one module tree: emotes and the emote wheel under game/cosmetics/
game/emotes.rs and game/emote_wheel.rs move to game/cosmetics/, their state into
Cosmetics (one Horizon field on Game), and the name mark comes from game/cosmetics.rs:
only game/cosmetics* names horizon_cosmetics now (layer plan M5, preparing the move
to a Horizon crate). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:06:53 +01:00
Horizon agentandClaude Opus 5.5 0deeb5cb29 discord_presence: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:05:14 +01:00
Horizon agentandClaude Opus 5.5 cf28dc7745 The Play with Friends screen leaves the engine library for the friends glue
friends_screen.rs moves from horizon-client-lib to game/friends/screen.rs, beside the host and
join glue; the pause menu asks friends_shown() and names the icon from game/friends (or
friends_off). Pure move, layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:04:42 +01:00
MSedgeMC 805414435e The anvil's read-only name box keeps its white text
AnvilScreen sets setTextColorUneditable(-1) beside setTextColor(-1), so the
read-only box is white too, not the EditBox default grey.
2026-11-09 15:04:08 +00:00
Horizon agentandClaude Opus 5.5 b05af110f7 Cargo.lock: horizon-client-lib no longer depends on horizon-cosmetics
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:04:03 +01:00
Horizon agent e8196d58c4 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	tools/layers.json
2026-11-09 16:02:07 +01:00
Horizon agentandClaude Opus 5.5 0ef952921d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:00:09 +01:00
Ludovic Marie 57b0346a6d Merge pull request #13923 from horizon-rust/layers/dualsense-out-of-input
🤖 Agent: the trigger-effect bytes are unchanged (the moved test keeps every assertion); horizon-client-lib's dualsense dep was unused. Removes 2 layer exceptions.
2026-11-09 15:59:45 +01:00
Horizon agentandClaude Opus 5.5 1ce7bf44df Cargo.lock: horizon-heartbeat, horizon-official's build script
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:59:27 +01:00
DUzzL 8507cc3e38 Hold entity ticking until the 2-column neighbourhood is loaded 2026-11-09 15:58:53 +01:00
MSedgeMC 4678c7c83e A read-only edit box refuses typed text, and the anvil's name box is read-only without an input
EditBox.setEditable(false) leaves a box focused and showing its value in
textColorUneditable, keeps the caret keys and copy, and refuses typed text,
Backspace/Delete, paste, and Cut's take (Cut still copies). AnvilScreen makes
its name box read-only whenever the input slot is empty; Horizon's anvil
inserted text anyway on the paths that did not route keys through the "input
present" gate (an IME commit calls station_text directly), and it never used
the uneditable colour.

ui::EditBox now has `editable` and `text_color_uneditable`, gates its editing
methods, and draws the uneditable colour. The anvil's rename box lives in
game/stations/anvil.rs (moved out of the over-size stations.rs) and follows
slotChanged.
2026-11-09 14:58:27 +00:00
MSedgeMC 56dadd398a The game-mode switcher applies on the debug modifier's mouse release
GameModeSwitcherScreen.mouseReleased switches to the hovered mode and closes
when `keyDebugModifier.matchesMouse(event)`, so binding the debug modifier to
a mouse button makes that button's release apply the choice (its key release
already does through keyReleased). Horizon handled only the key release, so a
modifier on mouse button 4 or 5 left the switcher open and changed nothing.

The release now asks the same binding through
`game_mode_switcher::release_applies`. Moving `drop_whole_stack` to
`game/inventory_input.rs` in the same commit keeps game.rs from growing past
its file-size limit.
2026-11-09 14:58:19 +00:00
MSedgeMC b2a0a39b5e A click on no slot ends the container's double click
AbstractContainerScreen.mouseClicked sets `this.lastClickSlot = slot` for
every click that reaches the container logic; a click outside the window is a
click on no slot. The double click is the same slot and button within
MouseHandler's 250 ms. Horizon kept only the last slot press, so a click
outside did not end the chain: click a slot, click outside, then click the
same slot again within 250 ms, and the second click gathered the container
(PICKUP_ALL) or quick-moved every stack, as a double click.

The bookkeeping is now `container_screen::double_click`, which records every
click's slot (None outside) and answers the double click, and inventory_press
asks it once for the press.
2026-11-09 14:58:13 +00:00
MSedgeMC 6e45279301 A container screen's first release clicks nothing
AbstractContainerScreen's constructor sets `this.skipNextRelease = true`, so
a mouseReleased with no press before it on that screen returns before any
slotClicked. That is the release of the click that opened the screen: a
chest's right-click sends its UseItemOn, the server opens the menu, and the
button goes up with the screen already open, over whatever slot the pointer
was on. Horizon's Clicks started with the flag false (and cancel_drag cleared
it), so that release picked up the slot now under the pointer.

Clicks::default and cancel_drag now start a screen with skipNextRelease true,
as a fresh AbstractContainerScreen does.
2026-11-09 14:58:05 +00:00
Ludovic Marie 31d430f40a Merge pull request #13929 from horizon-rust/split/core-shaders-to-render
🤖 Agent: pure move of core_shader.rs (vanilla resource-pack core shaders) into the renderer, its only user; horizon-shaders no longer exports it.
2026-11-09 15:56:59 +01:00
Ludovic Marie 60cbe7fcc3 Merge pull request #13925 from horizon-rust/split/render-hooks-checked-in
🤖 Agent: the render hooks' generated code is now committed; the game's build compares it token by token with the catalog's and fails with the xtask command when stale (checked by editing a doc line). Removes horizon-client-render -> horizon-mod-api-gen.
2026-11-09 15:56:55 +01:00
Horizon agentandClaude Opus 5.5 45b9aa75ff Ship the official executable; the "I'm playing" ping leaves the engine
Release builds (game-build.yml, build-musl.sh) build horizon-official and ship
it as horizon-client; the launcher's native-mod builds do the same, and its
generated executable for trait mods registers Horizon's parts (official
feature, off in the server build). The ping moves from horizon-client-lib to
the Horizon crate horizon-heartbeat, registered by horizon-official; the
heartbeat feature is gone. The Windows icon and stack move to
packaging/windows/exe.rs, shared by both executables' build scripts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:53:26 +01:00
Horizon agentandClaude Opus 5.5 482caf2db9 Launcher and telemetry-server locks: horizon-hooks under horizon-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:52:50 +01:00
MSedgeMC 9698eed74f Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:18 +00:00
MSedgeMC d3adab07d4 Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:13 +00:00
Horizon agentandClaude Opus 5.5 f9dfc692e5 window::display: the screen a window is on leaves telemetry (layer plan M5)
monitor() moves from the game's telemetry module to horizon-client-input's
window module (engine), with display() giving its size, refresh rate and
scale as plain data. horizon-ctl status reads it there; telemetry's
note_display keeps the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:48:52 +01:00
Evrim CicekandClaude Opus 5.5 b230cc559c Reset a guest vehicle's fall before the move is checked
handleMoveVehicle runs vehicle.move, and with it Entity.move's long-move
fall reset, before the moved-wrongly and colliding checks; a move put
back with absSnapTo keeps the reset. The boat and the horse reset after
those checks, so a rejected move kept the fall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:48:02 +03:00
Ludovic Marie 0eb683c454 Merge pull request #13917 from horizon-rust/decisions/upscaling-licence
🤖 Agent: interpolation.rs follows ffx_frameinterpolation.cpp like optical_flow.rs follows its .cpp, so it now carries the same MIT text; comment-only in Rust, no NVIDIA file anywhere.
2026-11-09 15:46:22 +01:00
Horizon agentandClaude Opus 5.5 d0dcb33382 discord_presence: nothing of the game crate left in it
Layer plan M5 (preparation): the game mode comes from horizon_command, and
the party's size from game/discord.rs (Play::Hosting's max) instead of
crate::lan, so the presence client can move to a Horizon crate as a pure
file move once the seam has an activity hook. No change for players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:17 +01:00
Horizon agent 233e09221d Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook 2026-11-09 15:44:07 +01:00
Horizon agentandClaude Opus 5.5 af9edec8b9 Cargo.lock: horizon-client-lib no longer depends on horizon-friends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:07 +01:00
Horizon agentandClaude Sonnet 5.5 f9d90fcb53 CI: keep the runners' persistent .git from filling their disks
actions/checkout sets gc.auto=0 in a checkout and fetches into it at every
job; our self-hosted runners keep that checkout, so nothing ever repacks, each
fetch adds a pack and a fetch cut short leaves its tmp_pack_* behind. On
2026-11-09 the desktop's checks runners held 1051 packs (111 GB in one
checkout, 33 GB in another) and filled its disk.

tools/ci/git-slim.sh removes old tmp_pack_*/tmp_idx_* files, collects a .git
over MAX_GIT_MB (5 GB) with git gc --prune=now and folds more than MAX_PACKS
packs with a geometric repack. runner-gc.sh runs it on each idle Mac runner
(the nightly one too), and playhorizon's job-started.sh runs it before the
2 GB reclone it had.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 15:44:01 +01:00
Horizon agentandClaude Opus 5.5 3bca3ed851 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:43:43 +01:00
Evrim CicekandClaude Opus 5.5 3e620360e9 A guest's horse resets its fall on a long move through a reset block or water
handleMoveVehicle's vehicle.move runs Entity.move, whose ray along a move
of a block or more (FALLDAMAGE_RESETTING, WATER) resets the fall before
the move counts. A guest's boat did this (#13021); the horse its client
moves (#13068) kept the fall and landed hurt. The vehicle fall clip group
reads the water boat's Pos[1] again now that the boat floats as vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:41:40 +03:00
MSedgeMC 5f6de8bae4 port_map: the minecart renderer does apply the hurt tilt and jitter
The note said hurt_time is not read and there is no per-cart jitter. Both
are in cart_geometry.
2026-11-09 14:41:38 +00:00
MSedgeMC 9a3c1024ff port_map: runIterationOnEquipment reads the hands too
The note said only worn armour is read. apply_enchantment_attributes walks
the four armour slots and both hands.
2026-11-09 14:41:08 +00:00
MSedgeMC 9e410f893a port_map: the minecart rows save its display block and offset
The two notes said a custom display block and offset are not kept. Both
to_nbt and from_nbt handle DisplayState and DisplayOffset.
2026-11-09 14:40:44 +00:00
MSedgeMC 9f47fec19d port_map: the hoglin rows say CannotBeHunted is not saved
It is: mob_to_nbt writes it for every hoglin and mob_from_nbt reads it into
extra.cannot_be_hunted, with a round-trip test.
2026-11-09 14:40:22 +00:00
Horizon agentandClaude Opus 5.5 83b9cfc1d8 Vanilla core shaders' translation moves from horizon-shaders to the renderer
Resource packs' core shaders (shaders/core/) are a format vanilla reads, so their translation is
engine code: core_shader.rs moves unchanged from horizon-shaders (the platform's shaders/ pack
crate) to horizon-client-render, its only user. Layer plan M6, a step toward the renderer not
depending on horizon-shaders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:39:12 +01:00
Horizon agentandClaude Opus 5.5 cbba6ead3a Emissive overlays' suffix through the pack-format hook
layer plan M6 (pack formats), step 3: the terrain's emissive overlays take
the pack's suffix from pack_hooks (OptiFine's emissive.properties, _e by
default); with no pack formats installed a pack has none. atlas_sources
checks a texture path stays in the pack root itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:39:02 +01:00
Horizon agentandClaude Opus 5.5 2a44a86383 Pack colours through the engine's pack-format hook
layer plan M6 (pack formats), step 2: the colours a pack replaces (dyes,
sheep, redstone, effects, the experience number, entity shadows, the
effect fogs) are an engine table (pack_palette::PackColors) that
Polytone's colors.json fills through pack_hooks; the polytone_colors
re-export goes away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:37:31 +01:00
Horizon agentandClaude Opus 5.5 24c8f66b32 Launcher backend.rs: import the platform's prepare, so the file doesn't grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:37:27 +01:00
MSedgeMC de9ab4646d port_map: the bow shooter is release_bow in game/use_hand.rs
The row's note still said the bow was fired from game.rs. release_bow is in
game/use_hand.rs after the hand split.
2026-11-09 14:35:58 +00:00
AdirajandMercury 3 bd2e287575 region: start sector allocation at the first free sector
RegionFile::allocate scanned the used-sectors bitmap from sector 0 on
every chunk write, so a long-lived region paid the full prefix scan per
save. Keep a low watermark of the first free sector; allocation starts
there (still first fit from 0, so placements are unchanged), and
force/free maintain it.

Co-Authored-By: Mercury 3 <[email protected]>
2026-11-09 20:05:56 +05:30
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
Horizon agentandClaude Opus 5.5 78a386e14d Cargo.lock: the dropped horizon-dualsense edges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:29:30 +01:00
Horizon agentandClaude Opus 5.5 51060ec716 horizon-hooks: rustfmt, Cargo.lock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:29:21 +01:00
Horizon agentandClaude Opus 5.5 1e1349cab7 Upscaling: interpolation.rs carries the FidelityFX MIT notice; docs say DLSS is a native mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:28:32 +01:00
Horizon agentandClaude Opus 5.5 b34ce5d7f3 Cargo.lock: the renderer has no build dependency left
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:27:05 +01:00
MSedgeMC 2982b03bdf Move the Tempt goal out of goals.rs and scale its range by visibility
goals.rs is over the 3000-line limit, so the goal moves to mobs/goals/tempt.rs (a pure move) and the visibility
scaling lands there: TemptGoal reads TEMPT_RANGE with TargetingConditions' two-block floor.
2026-11-09 14:27:03 +00:00
Horizon agentandClaude Opus 5.5 15da067a21 fix: deref the sky layers; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:26 +01:00
Horizon agentandClaude Opus 5.5 c391c8971c rustfmt: the longer platform paths
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:17 +01:00
Skr1zzzzandClaude Opus 5.5 2d2a2621f2 Move resetting inventory clicks and menu click sounds out of game.rs into game/menu_clicks.rs
Part of #11857. reset_inventory_clicks and player_menu_sound join game/menu_clicks.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:34 +03:00
Skr1zzzzandClaude Opus 5.5 067ca7dfb3 Move the frame's visual clocks out of game.rs into game/render_tick.rs
Part of #11857. visual_ticks, texture_clock and visual_millis join game/render_tick.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:26 +03:00
Skr1zzzzandClaude Opus 5.5 c584aac93d Move equipment attribute modifiers out of game.rs into game/player_equip.rs
Part of #11857. apply_equipment (LivingEntity.collectEquipmentChanges) joins game/player_equip.rs, which now imports equipment_modifier_applies from game.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:19 +03:00
Skr1zzzzandClaude Opus 5.5 c73a1df2a5 Move a bucket's aim and target out of game.rs into game/buckets.rs
Part of #11857. bucket_hit and bucket_target (BucketItem.use's place position) move to a new game/buckets.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:11 +03:00
Skr1zzzzandClaude Opus 5.5 0f4cd026d3 Move saving a level in place and the world icon out of game.rs into game/saving.rs
Part of #11857. save_level and tick_world_icon (GameRenderer.tryTakeScreenshotIfNeeded) join game/saving.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:22:55 +03:00
Horizon agentandClaude Opus 5.5 1db4e49c64 Render hooks committed: horizon-client-render stops depending on horizon-mod-api-gen
The renderer's RenderHooks trait and dispatch are made from native-hooks.json as before, but
committed in gfx/native_render/generated.rs: an engine crate may not build-depend on the
platform's generator (docs/layers.md, layer plan M6). xtask mod-api writes the file, and the
game's build, which still runs the generator, fails while it is out of date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:20:43 +01:00
Horizon agentandClaude Opus 5.5 382776e2ee Custom sky layers through the engine's pack-format hook
layer plan M6 (pack formats), step 1: the renderer's custom sky layers are
engine types (crate::custom_sky: Blend, Draw, Conditions, the SkyLayers
trait); OptiFine's sky format fills them through crate::pack_hooks, a hook
the platform installs at startup. The OptiFine glue moves to pack_formats/,
which only it names horizon_optifine and moves to a platform crate later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:38 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 652accb89b docs/layers.md: test modules and test files don't count toward the size limit
The section #13011 landed predates #13847 and this branch; it now says
which test code the check skips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 93e3025e31 File size check: tests.rs and *_tests.rs files don't count
They hold the tests moved out of a file (`#[cfg(test)] mod tests;`), and a
normal build doesn't compile them, the same reason inline test modules
don't count. The failure message sends new tests to a sibling
`<file>_tests.rs`, yet two such files were already over the limit
(mod_content_tests.rs 5,268 lines, level/tests.rs 4,175).

Fixes #13892

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:39 +01:00
Horizon agentandClaude Opus 5.5 b3dd537570 Play with Friends joins through an engine tunnel hook; horizon-client-lib drops horizon-friends
The engine keeps the friend: address form and the join; a resolver registered at startup
(net::tunnel) reads invite codes and opens the tunnel. Play with Friends registers its own
(game/friends/tunnel.rs) under the friends feature. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:26 +01:00
Horizon agentandClaude Opus 5.5 7cd8193a71 Layer seam: hooks for crates above the engine, and the official executable
horizon-hooks (engine): the Hooks trait (started, published, shutdown, empty
defaults), register before the game runs, the engine's dispatch. The platform
re-exports it as horizon_platform::hooks. horizon-game (platform): the game for
an executable to start. horizon-official (Horizon): the thin executable that
registers Horizon's in-game parts, not shipped yet. The "I'm playing" ping now
runs through the hooks. docs/layers.md: "How a Horizon crate plugs in".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:18 +01:00
Horizon agentandClaude Opus 5.5 4cada3276c Lockfiles: horizon-platform's new engine dependencies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:57 +01:00
Horizon agentandClaude Opus 5.5 5a9f3f777a Replay recording: the session hands packets to a recorder trait (layer plan M6)
net.rs no longer names the Studio recorder: it offers PacketRecorder, a tap
the recorder fills, and the privacy filter moves to the recorder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:39 +01:00
Horizon agentandClaude Opus 5.5 8d38384310 Emotes pose the player model through an engine hook, mobs::anim::BodyPose
Mob::emote holds a trait object the engine defines (anim::BodyPose) instead of
horizon_cosmetics::Playing; game/emotes.rs wraps Playing in it. horizon-client-lib
no longer depends on horizon-cosmetics (layer plan M5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:22 +01:00
Horizon agentandClaude Opus 5.5 d2dbe9227c official_servers: the official and partner server entries leave multiplayer.rs, behind horizon-extras
Layer plan M5 (preparation): Horizon's entries in the multiplayer list get
their own module in horizon-client-lib, gated by a new lib feature
horizon-extras (horizon-client's horizon-extras turns it on). It touches
servers.dat only through horizon_protocol::servers, whose new lock() replaces
multiplayer.rs's private SAVING mutex, so the module can later move to a
Horizon crate as a pure file move. A plain build now neither adds the
official server nor carries the withdrawn partner's address.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:30 +01:00
Horizon agentandClaude Opus 5.5 d0780fc55e Controllers: the DualSense trigger effects leave horizon-client-input
TriggerFeel's mapping to DualSense trigger effects moves from
horizon-client-input's gamepad.rs to game/pad/hardware/trigger_effects.rs,
the pad output code that already drives the DualSense. horizon-client-input
and horizon-client-lib no longer depend on horizon-dualsense (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:24 +01:00
Horizon agentandClaude Opus 5.5 92886cd964 Launcher and horizon-server reach the engine through horizon-platform
Layer plan M6: horizon-platform gains feature-gated re-exports (version,
no_ai, prepared_files always; world, protocol, prepare, server-files behind
features) and the two Horizon-layer crates use them instead of depending on
horizon-core, horizon-prepare, horizon-protocol, horizon-world and
horizon-server-files. Seven layers.json exceptions go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:02 +01:00
MSedgeMC dfe1e81cd7 cargo fmt 2026-11-09 14:07:12 +00:00
KnifethrowerandClaude Opus 5.5 53b77a0525 Structure templates: box a block's NBT
Every block of every loaded template paid 24 bytes for an NBT compound
that nearly none of them have, and templates stay loaded for the session.
Option<Box<Compound>> takes 8, so a BlockInfo goes from 40 bytes to 24.
What gets placed doesn't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:05:23 -05:00
MSedgeMC b768990c0c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 13:59:53 +00:00
MSedgeMC e3926a9a10 Move the subtitle and HUD-render gates out of game.rs
game.rs is over the 3000-line limit and may not grow, so the subtitle rule and the three HUD-render gates move to
game/subtitles.rs and game/hud_render.rs; game.rs ends below its base size.
2026-11-09 13:59:34 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 938365b9e8 HDR settings: UI Brightness in nits, apart from Paper White
UI Brightness was a share of Paper White, 25 to 100%: the HUD and menus could
only be dimmer than the world's white, and they moved whenever Paper White
did. It is now set in nits, as HDR games and mods set it: Auto (the default)
puts the HUD and menus at the paper white in use, as 100% did, so nothing
changes on screen by default; a value set by hand, on Paper White's steps
(10 to 480 nits by 10), holds them at that many nits whatever Paper White is,
below or above the world's white. The row reads like Paper White's
("UI Brightness: Auto (203)", "UI Brightness: 160") and shares its row code
and steps with it and Peak Brightness.

HDR output still takes a share of paper white, now UI nits over paper white
nits (resolve_output); its 0.25..1 clamp goes, so the HUD can be brighter
than paper white. The shader and the HUD composite are unchanged.

horizon-graphics.txt saves hdrUiNits, 0 for Auto. An older file's
hdrUiBrightness reads as Auto at 100% or with Paper White on Auto (its nits
aren't in the file), and otherwise as that share of the hand-set Paper White
in nits, to the nearest step. Calibration doesn't touch UI Brightness.

The frame-identity tool's float_visbuffer session pins a 200-nit paper white,
so its older file's 50% reads as exactly 100 nits and builds from before draw
the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:57:37 +01:00
Mik00000 89fe5eb063 Add safe async task offloading API for native mods
Introduce Host-driven task offloading API for native mods (mod_async_worker).
Tasks run on a dedicated mod worker pool ('horizon-mod-worker') isolated from chunk
and terrain generation. Panics in mod background jobs and deferred main-thread callbacks
are caught and reported per mod via crash attribution and contain without crashing pool
workers or the main game loop.

Callback queues belong to individual Game instances (window and server thread in single
player), reached exclusively through Host without global ACTIVE_QUEUE state or test races.
2026-11-09 15:50:01 +02:00
MSedgeMC 43e356e823 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 13:43:10 +00:00
Oloion e0b91d71bf Compare pre- and post-collision delta movement in fall flying
LivingEntity.handleFallFlyingCollisions in vanilla 26.3 compares the
horizontal movement vector passed to move() with the delta movement left
after move() (where restitution zeroes out colliding axes), not the
position delta travelled vs initial velocity.

Using post-move velocity prevents the crash sound from repeating on
subsequent ticks while pressed against a wall, while still playing the
single impact sound in both survival and creative mode.

Fixes #12304
2026-11-09 16:41:16 +03: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
MSedgeMC 56728e147f port_map: the slider sprite rows stop being inline gaps
Both rows recorded the drag outline and the hover-only handle as known
divergences; they now name the fn that draws vanilla's rules.
2026-11-09 13:37:18 +00:00
MSedgeMC 382cf33dd9 test: pin the slider sprites to focus and the drag
The old test asserted the drag outline; it now checks the four cases vanilla
distinguishes (hover, drag, keyboard focus, focus plus drag) and that an
inactive slider lights nothing.
2026-11-09 13:37:13 +00:00
MSedgeMC 01ef7b1657 fix: the slider's track lights on focus, not while it is dragged
AbstractSliderButton.getSprite is `isActive() && isFocused() &&
!canChangeValue` and getHandleSprite is `isActive() && (isHovered ||
canChangeValue)`, where canChangeValue is the mouse drag. Horizon drew the
track outline while dragged (vanilla suppresses it then) and never on
keyboard focus, and lit the handle on keyboard focus, which vanilla leaves
plain.
2026-11-09 13:37:13 +00:00
Skr1zzzzandClaude Opus 5.5 630258d2a0 Move ticking chunk tracking and scheduled ticks out of game.rs into game/block_ticks.rs
Part of #11857. sync_tick_chunks, tick_moving_pistons and tick_scheduled join game/block_ticks.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:40 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 9f376f7061 The stations prepared-data test loads the full item table
Items::tags_only has no items, so the anvil part of
prepared_vanilla_data saw a diamond sword with max damage 0: not
damageable, no repair, cost 0 where vanilla gives 1. The test now loads
Items::load, and the whole test passes on freshly prepared data.

Fixes #13883

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:33:22 +01:00
SenjinandClaude Opus 5.5 4a70193562 Shader packs: a shadow program with no output left keeps its water, without the boats' patches
The shadow pass's translucent terrain takes one vertex output more for the boats' water patches
(the vertex's camera-relative place). A pack whose shadow program already writes as many as the
GPU takes had that program refused, and with it its translucent terrain's shadows. It now gets the
program without the patches instead (`Post::shadow_water_plain`), with a note: the water's light
shows in the boats there, as before the patches, and the rest of the shadow pass stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:29:19 +01:00
Vinícius MoreiraandClaude Opus 5.5 f079407800 A mob keeps a negative DeathTime or HurtTime
LivingEntity reads HurtTime and DeathTime with getShortOr and writes them
back as (short) casts, nothing clamped. Horizon clamped both to
0..=32767 on save, so a negative value read back as 0s.

Fixes #13846

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:29:06 -03:00
MSedgeMC e2482f0e1d port_map: the containerTick row names the fn where it now lives
The swap moved to game_mode.rs for the file-size limit.
2026-11-09 13:28:25 +00:00
MSedgeMC 0d6aaca77d fix: keep the inventory swap out of commands.rs
commands.rs is already over the 3000-line limit and may not grow, so the
screen swap moves to game/game_mode.rs beside set_mode, which is where the
mode's own effects live. The call site is one line, and the file ends a line
shorter than the base.
2026-11-09 13:25:43 +00:00
Skr1zzzzandClaude Opus 5.5 10ffe236fb Move breaking a block and its drops out of game.rs into game/block_breaking.rs
Part of #11857. Mining with a held tool, break_block and the player's break, a broken block's drop, the destroy-progress cracks and a bundle dropping its content move to a new game/block_breaking.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:25:01 +03:00
MSedgeMC f2be6f018c Keep goals.rs under the file-size limit
The targeting tests move to mobs/goals_targeting_tests.rs, so goals.rs (over 3000 lines) shrinks from 3740 to 3611
while the new tempt-range test joins them.
2026-11-09 13:22:55 +00:00
Skr1zzzzandClaude Opus 5.5 8704d0e086 Move the level's explode entry points out of game.rs into game/explosion.rs
Part of #11857. ServerLevel.explode's entry points (explode, explode_mob, explode_sulfur_cube) join game/explosion.rs; inside it they name ExplosionSource and BlockInteraction without the explosion:: prefix. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:38 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 5d5f14b7c5 CI: the size, layer and address checks compare with HEAD^1 on pull requests
verify checks out GitHub's merge commit, but these three checks compared
it with pull_request.base.sha: main at the pull request's last push. As
main moves on, main's own changes since then counted as the pull
request's, so pull requests failed for files they never touched (on
#13027's merge: 17 files flagged, 16 not in the PR), and a file main had
shrunk could grow unnoticed. HEAD^1 is the main the merge commit puts
the pull request on. Pushes to main run as before, without a base.

Fixes #13874

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:18:37 +01:00
Skr1zzzzandClaude Opus 5.5 afe1554048 Move the held and worn item helpers out of game.rs into game/held_items.rs
Part of #11857. The stack in the acting hand, the hand in use, with_hand, Inventory.add and the worn glider and freeze-immune gear move to a new game/held_items.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:15 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 ba6c54fe42 HDR calibration: the hint wraps, and the window comes back maximized
Calibrate's hint for each step was one centred line. At a large GUI scale
the Paper White hint is wider than the window, and its start and end were
cut off. It now wraps within the window, and the band of controls grows
up by the lines it adds. The test picture HDR output draws ends at that
band, so `picture`, `draw` and `click` share one layout, which now takes
the font.

Calibration turns a window that isn't fullscreen into fullscreen for its
test pictures. Leaving fullscreen gives a window back at its restored
size on Windows, so a maximized window came back un-maximized after Save,
Cancel or Escape. Calibration now remembers whether the window was
maximized and maximizes it again when it leaves (#13829).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:17:51 +01:00
Florian ErnstandClaude Opus 5.5 2ddadf8edc Nightly release steps: optional archives work with bash 3.2
"${arm64[@]}" and "${musl[@]}" are unbound when the array is empty
in bash before 4.4 (macOS's /bin/bash is 3.2), so the 3 cases of
test_optional_release_assets without an optional archive failed locally
and pr_ready showed Python tests red on every Mac. The
${a[@]+"${a[@]}"} form expands to nothing there and is the same on
newer bash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:17:03 +01:00
MSedgeMC d2efe0374c port_map: InventoryScreen.containerTick names the screen swap
The row said the switch to the creative screen was missing; it is the same
tick rule the creative screen already had in reverse.
2026-11-09 13:08:57 +00:00
MSedgeMC 278db50cfd test: pin which screen a mode change leaves the survival inventory on
Creative gets the creative inventory, survival keeps the crafting one, a
spectator still closes it, and any other screen is untouched.
2026-11-09 13:08:53 +00:00
MSedgeMC 12dbbe2251 fix: a survival inventory becomes the creative one when the mode changes
InventoryScreen.containerTick replaces its screen with a
CreativeModeInventoryScreen once the player has infinite materials, so the
player stays in a menu. Horizon closed the inventory instead, which lost the
screen a creative player expects after /gamemode creative. The spectator path
still closes it (vanilla has no branch there and leaves it open).
2026-11-09 13:08:53 +00:00
Florian Ernst 134b443675 Start CI again: the last push started no checks run 2026-11-09 14:03:56 +01:00
MSedgeMC 7903537503 port_map: LoadingOverlay.isReadyToFadeOut names the hold
The row was filed as missing while the overlay was taken down as soon as the
worker finished.
2026-11-09 13:01:40 +00:00
MSedgeMC 0c989e88e1 test: pin the overlay's one-second hold
The hold is 1000 ms, a staged pack waits it out even when the worker is long
done, and a zero hold applies at once.
2026-11-09 13:01:37 +00:00
MSedgeMC 0e5d9f1d12 fix: a pack reload keeps its overlay up for a second before it applies
LoadingOverlay.isReadyToFadeOut holds a mid-game reload's overlay while
Util.getMillis() - fadeInStart < 1000L, so a pack that stages in a few frames
still shows the progress for a second instead of flashing for one or two.
drive_pack_reload applied the staged pack the moment its worker finished.
2026-11-09 13:01:37 +00:00
Skr1zzzzandClaude Opus 5.5 327a39bc56 Move block crack and transform particles out of game.rs into game/block_particles.rs
Part of #11857. particle_sprite, crack_particle (ClientLevel.addBreakingParticles) and transform_particle join game/block_particles.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:00:09 +03:00
Skr1zzzzandClaude Opus 5.5 4c82ef0d9e Move a container screen's slot list out of game.rs into game/inventory_slots.rs
Part of #11857. The open container screen's slot list (container_kind, slots, slot_count) joins the slot layout in game/inventory_slots.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:55:04 +03:00
MSedgeMC 2c3c6e355c port_map: the advancement tab rows say the packets go out
The removed row was filed as missing and the setSelectedTab row carried the
same gap in a note.
2026-11-09 12:52:53 +00:00
MSedgeMC 5399fce12b test: pin the two packets the advancements screen sends
openedTab carries action 0 and the advancement id, closedScreen action 1 and
no identifier at all - the read side that handleSeenAdvancements uses reads
a close as two bytes.
2026-11-09 12:52:48 +00:00
MSedgeMC 9a6fdc6b65 fix: the advancements screen tells the server which tab it opened and that it closed
ClientAdvancements.setSelectedTab(tab, true) sends
ServerboundSeenAdvancementsPacket.openedTab before it compares the tab, and
AdvancementsScreen.removed sends closedScreen() - action 1, no identifier.
Horizon sent neither: a LAN server's handleSeenAdvancements never heard a
tab, so a guest's selected tab stayed empty and no SelectAdvancementsTab
answer ever came.
2026-11-09 12:52:48 +00:00
Skr1zzzzandClaude Opus 5.5 35bdf494d1 Move spawning item entities, drops and block experience out of game.rs into game/item_entities.rs
Part of #11857. Spawning and dropping item entities, Block.popResource, Player.drop and the experience a block or a furnace gives join the rest of game/item_entities.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:34 +03:00
Skr1zzzzandClaude Opus 5.5 49ffad5aee Move the weather and sky darkness out of game.rs into game/weather.rs
Part of #11857. Pure move: whether the level can have weather, the rain and
thunder levels, where it rains or snows and the rain height, the weather's
effects each tick, and Level.getSkyDarken with isDarkOutside. Methods that were
private in game.rs become pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:09 +03:00
Horizon agentandClaude Opus 5.5 9163335028 command_exec: the command block case scores the player, not a new holder
A fake holder (#cb) created here stays tracked after its objective is
removed, and the later /scoreboard players list then lists the holders
in another order than vanilla's hash map. Horizon's open-hash order for
that insertion is a separate bug; this case is about LastOutput.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:14 +01:00
Dylan FerreyraandClaude Opus 5.5 791468c660 Move the command tree's codecs out of play.rs into play/commands.rs
CommandNode, ARGUMENT_TYPES, ArgumentProperties, the two private
helpers (read_argument_properties, acyclic) and Commands' Codec move
byte for byte into packets/play/commands.rs. play.rs re-exports the
four public items, so every `packets::play::…` path still resolves.

The port map follows: the five rest-007 rows that name
ArgumentProperties::read point at the new file, and five rest-012 rows
map what main's ledger only matched by name, now that play.rs no
longer cites ClientboundCommandsPacket: Entry.write, ArgumentNodeStub.write
and readNode to Commands' Codec, VecDelta.Linear/Stepped.decode to the
MoveEntity arms of Game::handle_packet (checked against the 26.3
bytecode).

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:48:16 -03:00
Horizon agentandClaude Opus 5.5 0e687efd6e Cloud custom_particle: decode as the 26.3 option codecs do
ColorParticleOption keeps the colour's alpha (ARGB_COLOR_CODEC: an int's
top byte or a fourth float); colour lists have a fixed length and are
packed to 8-bit channels (ARGB.colorFromFloat); dust scale, sculk_charge
roll and shriek delay are required, and dust scale must be in [0.01, 4];
a spell's colour defaults to white. What fails to decode falls back to
the potion swirl, as vanilla's failed decode leaves no custom particle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:46:58 +01:00
Florian ERNSTandCodex 7ab182b08e Fix typed block-state predicate range comparisons
Co-Authored-By: Codex <[email protected]>
2026-11-09 13:44:35 +01:00
Horizon agentandClaude Opus 5.5 8a2a85a683 pot_hopper runs by name only; drop the harness contract test
pot_hopper needs --side and --work-dir, which an automatic parity run
doesn't pass. tests/test_pot_hopper_contract.py pinned the corpus row
counts and the harness's own compare, not game behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:43:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 3319947bdf Polytone: creative tab modifiers
A pack's creative_tab_modifiers change a creative tab's icon, its name (text and colour),
and its items: removals by the wiki's item predicates (or, and, not, tag_match,
items_match, itemstack_match by item, id_match, true), then additions at the end or
before or after the first item a predicate matches. Measured on the real mod (icon and
red name on the building blocks tab, with the inventory opened through XTEST).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:42:51 -03:00
Horizon agentandClaude Opus 5.5 3a6e1aabe2 Camel jump: the helper sits above the test module; drop a copied doc
The camel_jump_tests header carried TestCommand.showPos text from another
test file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:41:12 +01:00
Vinícius MoreiraandClaude Opus 5.5 d3392cb35b A command block saves its last output as a component
BaseCommandBlock.save writes LastOutput with ComponentSerialization.CODEC:
the time-stamped feedback component with its colours and click events.
Horizon wrote its plain characters as a string.

Fixes #13077

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:40:24 -03:00
Horizon agentandClaude Opus 5.5 db07aba271 Drop a rebase leftover and a fixture nothing runs
respawn_world_point_tests.rs.orig is main's copy of the test file; the
spawn-info Python fixture and its Java decoder match no harness name, so
no runner ever calls them. The Rust test covers the sea level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:40:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 75bb6a09ff HDR settings: the HDR page's shortcut key goes
The HDR page loses its Shortcut group and row: a key that opened the
graphics screen at HDR from the game. No other graphics page has a key of
its own, and the row was out of place there. The game no longer watches for
the key, and Done after that screen no longer has a way back into the game
that only the key used.

The hdrKey setting goes from horizon-graphics.txt. An older file's hdrKey
line loads like any unknown key, without error, and the next save drops it.
Its three strings go from every language file; nothing else used them.
Horizon's other key binds (sort, zoom, emotes) are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:27:27 +01:00
Florian Ernst 8fe0b417be Start CI again: the previous run stayed queued with no jobs 2026-11-09 13:24:57 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4092f19f56 Docs: what a render pass keeps so HDR works
Upscaling, anti-aliasing, sharpening, frame generation, post effects and
captures each break HDR in their own way when written for SDR alone, mostly
without a test failing: frame identity runs with TAA and frame generation off.
docs/hdr-passes.md says where such a pass goes, what the frame holds, the
rules it keeps and how to check it; AGENTS.md and the docs index point to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:14:43 +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
Mik00000 a9a14c810e perf(mobs): optimize sensory raycasts, section caching, and despawn checks 2026-11-09 13:35:02 +02: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
Vinícius MoreiraandClaude Opus 5.5 9c1aca3474 Polytone: colormap tints cost a twelfth as much to mesh
The mesher's pack colormaps (the freepack's grass and leaves) sampled every block of a
section, each with its own biome zoom, through SipHash maps. Now:
- a section of one biome skips the per-block zoom (as vanilla tints already did);
- a colormap whose expressions don't read the height (no o.y) is sampled once per column
  over one biome (Program::reads_only says what an expression reads);
- the tint caches use the Fx hasher, and expression calls keep up to four arguments on
  the stack instead of a Vec.
Same colours (checksum of the timing test unchanged). One section, 16 layers of 256 tints
with a biome-id and noise colormap at Biome Blend 2 (release): 2.16 ms before, 178 µs now;
one layer 274 µs before, 138 µs now (mesher::tint_tests::colormap_tint_cost, ignored).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:49:33 -03:00
Vinícius MoreiraandClaude Opus 5.5 a62af71cef Polytone: environment.horizon_height
The sky's dark disc draws below a pack's horizon height instead of vanilla's 63 (flat worlds
keep their floor), measured on the real mod; sky_render_distance changes nothing there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:37:35 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b38e07d1d Polytone: biome_colors replace vanilla's biome colour functions
biome_colors.grass/foliage/dry_foliage/water (a colormap or a flat colour) colour every
block tinted by that function, under the block modifiers' own colormaps; without it, a block
modifier's colormap on grass_block, oak_leaves or leaf_litter (or a fluid modifier's on
water) becomes the function, as the wiki says Polytone does. Measured on the real mod:
biome_colors.grass also wins over a dark forest's special grass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:37:35 -03:00
Florian ErnstandClaude Opus 5.5 7df5c204c4 Call the camel test scene's own game() where a local shadows it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:23:58 +01: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
Nikita Maximilian Grimm 2d9576f84d Merge remote-tracking branch 'origin/main' into parity/piston-falling-block 2026-11-09 11:01:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3e7da21a05 A falling block that lands in a moving piston's block stays and is pushed
FallingBlockEntity.tick neither places, drops nor discards a block that
comes down where a piston is extending, and checks that before cancelDrop:
it slows like any landing and stays a falling block, so the piston pushes
it on. Horizon took the block out of the list before the landing returned
Landing::Continue, so the block was lost.

Fixes #13793

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:59:33 +01:00
Florian ErnstandClaude Opus 5.5 92dd7886f0 Keep big LAN files from growing: tests and spawn_info in their own files
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow, and the sea level field adds a line in each. Hub's
spawn_info moves to lan/hub/spawn_info.rs; three inline test modules
move to their own files, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:54:51 +01: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 8c154acd4a Keep game.rs and lan_actions.rs from growing: riding jump in mounts.rs
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow. The guest's START_RIDING_JUMP handler moves beside the
mount code it calls, and the camel test module is declared there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:53:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8ff04112a2 HDR output: Auto's grace ends on a fresh read after a settings change too
A settings change at the end of Auto's grace (a slider, F11) applies
the output before the frame's own display poll. It ended the grace on
the report read when the grace started, so a display that had come
back meanwhile still turned HDR off, and the frame's poll after could
no longer confirm it: the timer was gone.

The output now reads the display before the grace ends, whichever comes
first, the frame's poll or a settings change. A read taken since the
deadline counts, so each end of the grace reads the display at most
once. The two-second poll is unchanged otherwise, and Wayland's events
need no read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:44:26 +01:00
MSedgeMC d595ebdd5e Invisible players tempt animals only within two blocks 2026-11-09 09:40:59 +00:00
MSedgeMC 7b9719af3d Banner chases stop when the raid ends 2026-11-09 09:40:54 +00: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 4f69ceeb88 Begging wolves ignore spectators 2026-11-09 09:31:15 +00:00
MSedgeMC bf28b98a03 port_map: DirectionalSoundInstance.setPosition is directional_position
The row pointed at tick_end and said the flash sound has no position. The
10-block directional position is implemented and the flash sound uses it
from tick_end_flash.
2026-11-09 09:31:09 +00:00
MSedgeMC 779fb404f5 port_map: ClientLevel.syncBlockState does snap the player out
The note said the player is not moved when the restored block would hold
them. end_predictions checks block_meets_player and snaps through
snap_target/snap_player_to.
2026-11-09 09:30:48 +00:00
MSedgeMC 196f313399 port_map: the sprite animation rows name animate_with
SpriteAnimator::animate is gone; animate_with is the public pass that
advances every animated layer for the tick.
2026-11-09 09:30:23 +00:00
MSedgeMC 14f5904ee3 port_map: SheepRenderState.getWoolColor is the layers colour
sheep_wool_rgb is a re-export alias, not a function, so the row was a map
gap. The colour is chosen in render.rs::layers, jeb_ through jeb_wool_rgb.
2026-11-09 09:30:02 +00:00
MSedgeMC 3e0cd059d6 port_map: the sheep colour and glow squid light rows name the code
sheep_wool_rgb is a pub use alias, not a function, so both sheep rows were
map gaps. Point them at the layers function that picks jeb_wool_rgb; the
glow squid row now names glow_squid_block_light, which is implemented.
2026-11-09 09:29:41 +00:00
MSedgeMC 0e811d78e5 port_map: celestial_geometry lives in game/frame_geometry.rs
The note still named game.rs after the frame geometry split. The function
is in game/frame_geometry.rs; game.rs only calls it.
2026-11-09 09:27:46 +00:00
MSedgeMC 6fea5b936a Pufferfish puff goal ignores spectators 2026-11-09 09:21:05 +00:00
MSedgeMC fd04e8e867 Enderman stare goal ignores spectators 2026-11-09 09:10:55 +00:00
Nikita Maximilian Grimm 687f1bfd12 Merge remote-tracking branch 'origin/main' into fix/timeout-seconds-every-run 2026-11-09 10:10:06 +01:00
Florian ErnstandClaude Opus 5.5 3b735090e4 A ridden camel's dash posts the entity_action game event
Squashed and rebased onto main from #12473.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:09:08 +01:00
Mik00000 dde35f2b2e perf(worldgen): expand structure locate rings across candidate sets with vanilla stop order 2026-11-09 11:05:21 +02: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
MSedgeMC 704c3906e6 A brewing stand reads a numeric speed multiplier
BrewingStandBlockEntity.loadAdditional reads speed_multiplier with
ValueInput.getFloatOr, which takes any numeric tag. Horizon only accepted a
Float tag and defaulted every other numeric form to 1.0.
2026-11-09 09:04:14 +00:00
MSedgeMC 0413376752 A jukebox plays the song its disc names
JukeboxSong.fromStack reads the stack's own minecraft:jukebox_playable
component, and a removed component names no song. Horizon resolved records
through the item's default component only, so a disc that overrode or removed
the component played, or stayed silent, against the stack in the slot.
2026-11-09 09:04:07 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 c26fb23197 HDR output: Auto keeps HDR through a moment's loss of the display's report
With HDR on Auto, a display that wakes, a window moved to another
display, or a moment around Win+Alt+B can report SDR or nothing for one
read and HDR again at the next. Each such read turned HDR off at once
and on again: two swapchain reconfigurations, and on the layered float
frame a new renderer and a remesh, so the player saw black flashes and
a stall for nothing.

Now, while Auto presents HDR and the surface still offers that
encoding, a report that turns false or unknown keeps HDR on for 500 ms,
at the paper white, peak and black last applied. The applied output,
the shader Frame, the settings' active state and the frame the game
asks for stay as they were. A positive report before then clears the
timer with no swap; a loss that lasts turns HDR off once. Where the
system is polled, the display is read again at the deadline rather
than trusting a read up to two seconds old; where it sends events, the
deadline is served without one. The two-second poll stays otherwise.

Off and On act at once, entering HDR is never delayed, and an encoding
the surface no longer offers is never kept: the output switches to the
one offered, or SDR, at once. The timer is each renderer's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:03:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 50db6475fe --timeout-seconds ends every run, not only a frame-limited one (#13751)
A control-socket run started with --timeout-seconds never stopped when its
harness died. The flag now applies to every run; a frame-limited run keeps
its 120 s default and a run without either has no limit. --help lists it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:03:11 +01:00
RINNECODERandClaude Opus 5.5 a84771f764 Sulfur cube: MAX_FUSE is entity data 19 and FROM_BUCKET 20
SulfurCube defines MAX_FUSE (an int, -1 until primed) before FROM_BUCKET
(a boolean), right after AbstractCubeMob.ID_SIZE (18). Horizon sent and read
them the other way round, so a 26.3 client tracking a sulfur cube got a
boolean at 19 and disconnected (#13721). The server now sends the fuse at 19
with vanilla's -1 default and the bucket flag at 20, and the client reads
them there; a fuse of -1 is not primed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:25 -06:00
Florian ErnstandClaude Opus 5.5 58ac340c1c Let hoppers insert into the decorated pot saved item slot
Squashed and rebased onto main from #11599.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
Florian ErnstandClaude Opus 5.5 d2612fd508 Use generator sea level in guest spawn info
Squashed and rebased onto main from #11458.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
DUzzL a01f9d5883 Area effect clouds: honour and save custom_particle 2026-11-09 09:57:05 +01:00
RINNECODERandClaude Opus 5.5 562175df6c Streaming: meshes waiting for upload count against the meshing room
Uploads take a few ms of each frame. At a wide render distance and a low frame
rate they fall behind, and meshing kept going: a heavy modpack at 64 chunks held
4.3 GB of finished meshes in pending_uploads during a flight, enough to swap a
24 GB Mac. The room for new mesh jobs now subtracts that backlog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:52:32 -06:00
RINNECODERandClaude Opus 5.5 13f692f8f0 Design-system text: § codes are formatting, glyphs Geist lacks come from the pack's font
A resource pack's lang can give strings `§` colour codes ("§6Options...")
and private-use characters its `font/default.json` maps to bitmap icons.
Geist printed the codes literally and drew tofu for the icons. `Ui::layout`,
shared by `measure` and `text_line`, now drops `§` codes like
`StringDecomposer.iterateFormatted` and draws a character Geist lacks with the
pack's default-font glyph: vanilla's 9px line scaled onto Geist's, or at GUI
scale when the whole label is such glyphs, as the pack draws it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:47:25 -06:00
k3yhandClaude Opus 5.5 bc16f9aedc Mobs swim, float and drown in content mods' fluids
Step 5 (second part) of #6301: a mob's fluid scan finds the mod fluid it is
deepest in (a mod liquid block holds its fluid only in the fluid table). The
mob moves in it as in water, floats and rises in it when can_swim
(Mob::floats now holds the brains' and FloatGoal's shared condition), loses
air when can_drown, is put out when can_extinguish, is pushed by its own
current at motion_scale when can_push_entity, and has its fall distance
scaled by fall_distance_modifier. Mob currents no longer pick a mod fluid by
its template.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:47:12 -05:00
k3yhandClaude Opus 5.5 3eb0cdc6f5 Content mods' fluids move the player and push items by their numbers
Step 5 (first part) of #6301: each mod fluid gets its own tag in
EntityFluidInteraction, as NeoForge tracks each FluidType, so it is not
water to the game. Its current pushes the player, items, orbs, armour
stands and boats by motion_scale when can_push_entity; the player moves in
it as in water, swims and rises when can_swim, loses air when can_drown, is
put out when can_extinguish, and has the fall distance scaled by
fall_distance_modifier. Unset numbers are the template's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:47:11 -05: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
Vinícius MoreiraandClaude Opus 5.5 fb354374e3 Polytone: item modifiers' damage bar colours
An item modifier's bar_color of the damage type (an <item>_bar.png alone, or a json with
bar_color "damage" and its png) colours the durability bar by the stack's damage: the first
pixel undamaged, the last at full damage, rounded, as measured on the real mod. The item
colormap (tint) changes nothing on the real mod's Fabric 26.3 build, so the freepack's
grass item modifier already looks the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:22:03 -03:00
RINNECODERandClaude Opus 5.5 644f4d80f7 Chat: apply a server's filter mask to player messages
The client decoded the player chat packet's `FilterMask` but only used it
to check the signature: on a server that filters chat, a fully filtered
message was still shown and narrated, and a partly filtered one showed
without its `#` masking.

As `ChatListener.showMessageToPlayer` does, a fully filtered message is
now hidden (and counted as processed, like a blocked sender's), and a
partly filtered one shows and narrates
`FilterMask.applyWithFormatting(signedContent)`: each masked UTF-16 unit
a dark grey `#` with the `chat.filtered` hover.

Fixes #13674

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:19:19 -06:00
Vinícius MoreiraandClaude Opus 5.5 8125ddb602 Polytone: a pack's leash texture
A pack's entity/lead.png textures the leash: each of its 24 segments, from the leashed
end, takes its row of the texture, unshaded, as measured on the real mod. Without one the
leash keeps vanilla's brown checker. The custom heart containers (polytone/heart/container_*)
change nothing on the real mod's 26.3 build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:14:27 -03: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
MSedgeMC 1a26268488 A screen pauses the single-player world when its isPauseScreen says
Minecraft.pause is `hasSingleplayerServer() && Gui.isPausing()`, and
Gui.isPausing is the open screen's isPauseScreen. Horizon kept that answer in
a matches! list beside pauses_game instead of on the Screen, and two screens
disagreed with 26.3. SocialInteractionsScreen overrides isPauseScreen false,
so the world keeps running behind it; Horizon paused. StructureBlockEditScreen,
JigsawBlockEditScreen and the two test block editors inherit Screen's true, so
opening one freezes a single-player world as a command block's screen already
did; Horizon left them out of the list.

The answer is now `Screen::is_pause_screen`, one match over every variant, and
pauses_game asks it. The book in hand, the win screen and a dialog still answer
from their own state where they live.
2026-11-09 08:03:34 +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
MSedgeMC 53c6cd4fa5 A container screen ignores the hotbar keys while a stack is carried
AbstractContainerScreen.checkHotbarKeyPressed answers nothing unless
`this.menu.getCarried().isEmpty() && this.hoveredSlot != null`: with a stack
on the cursor, keySwapOffhand and keyHotbarSlots send no slotClicked at all.
The mouse hotbar keys asked `carried.is_none()` at their call site, but the
keyboard path (Game::inventory_hotkey from Screen::keyPressed) did not, so
pressing 1 or F over a slot with a stack carried sent SWAP. The menu's SWAP
branch does not look at the carried stack, so it moved the hovered slot's
item into the hotbar slot while the stack stayed on the cursor.

The guard now lives in `container_screen::hotbar_swap`, the Rust counterpart
of checkHotbarKeyPressed, and `inventory_hotkey` asks it for the off hand and
the nine hotbar slots.
2026-11-09 08:00:48 +00:00
RINNECODERandClaude Opus 5.5 26d55e5d39 Campfire smoke goes through the particle limiter
`make_campfire_smoke` made both of its particles with the engine's
`create`, which skips `ClientLevel.doAddParticle`'s limiter, so with
Particles on Minimal or Decreased, or a campfire more than 32 blocks
away, a smoking (doused) campfire still drew every plain `smoke` puff.

As `CampfireBlock.makeParticles` does, the cosy/signal smoke now goes
through `addAlwaysVisibleParticle(.., true, ..)` (`add_with(.., true,
true)`: always made, but it still rolls the Particles option) and the
plain smoke through `addParticle` (`add`).

Fixes #13663

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:59:31 -06:00
RINNECODERandClaude Opus 5.5 7bd4244e2f Stats screen: mined blocks count under the item that places them
The Items tab keyed its rows on the raw `minecraft:mined` stat key, a
block id, so wheat, redstone wire, tripwire, wall torches and other blocks
whose item has another id got a row for a non-item with "-" under Mined,
while the real item (wheat seeds, redstone, string, torch) showed no mined
count. Rows now take `Block.asItem` of a mined block (air dropped), and a
block item's Mined column reads its block's count, as vanilla's
`StatsScreen.ItemStatisticsList` and `ItemRow` do.

Sorting follows `ItemRowComparator`: "-" counts as -1, equal values are
ordered by item registry id, and the first click on a column sorts
biggest first (it sorted smallest first).

Fixes #13664

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:51:34 -06: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
DUzzL f82a1bd4fb A cured zombie villager drops its picked-up gear instead of wearing it 2026-11-09 08:30:18 +01:00
DUzzL 60b7307b3f Break cushions in lava at the check interval 2026-11-09 08:19:30 +01: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
Arthur BlackandClaude Opus 5.5 e960166932 Touch world gestures and the dynamic joystick (Android touch input, part 3b)
- A short tap on the world acts once where it landed (or at the crosshair for aimed items); take_tap() hands it to the game with the item rule read when the finger landed
- A touch held 350 ms without dragging mines or uses the item, and keeps doing so while it turns the camera
- aim, holding and holding_use tell the game where and how this tick's world action goes; latch_use() keeps a hold as use
- pointer() places the touch marker in the tap schemes
- Optional crosshair gestures, attack button steering, and a dynamic joystick that appears where the finger lands inside a configurable strip
- Settings gain crosshair_gestures, attack_button_look, dynamic_joystick and dynamic_area_x/y (serde defaults keep saved files)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-08 22:57:50 -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
Nikita Maximilian Grimm a4649dc6a1 Merge hdr-crash-recovery (main with the gate tests) into hdr-output-o2
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/hdr.rs
2026-11-09 07:21:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b52536c729 Merge hdr-output-owner (main with the gate tests) into hdr-crash-recovery
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:20:44 +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
voidsip 9368197061 Format relocated native-mod tests with their regression 2026-11-08 23:43:37 -06:00
voidsip a0a4a905ad Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/10914-native-content-assets
# Conflicts:
#	launcher/src/native_mods.rs
2026-11-08 23:42:26 -06:00
voidsip 034fd1593e Keep native content source and startup preparation in focused modules 2026-11-08 23:30:33 -06:00
MSedgeMC 5ed7535acd port_map: a bee's pair of save methods
`tools/port_map/animal-1.tsv` had no answer for `Bee.addAdditionalSaveData`
or `Bee.readAdditionalSaveData`. Both now name `mobs/bee.rs::to_nbt` and
`from_nbt`: the hive and flower positions, the nectar and sting state, and
`NeutralMob`'s `anger_end_time` and `angry_at`.
2026-11-09 05:18:34 +00:00
MSedgeMC 1bb2149e88 test: an angry bee's target through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A bee angry at the player (`Bee.angry_at =
Some(Who::Player)`, its timer 500) saves `angry_at` as the offline player's
UUID; the reloaded bee has the same player target and the same timer. A calm
bee writes no `angry_at`, even over a compound that still names one.

Without the write the save has no `angry_at` (left `None`, right `Some(IntArray([...player...]))`),
and the reloaded bee has no target (left `None`, right `Some(Player)`).
2026-11-09 05:18:34 +00:00
MSedgeMC d59333ab3a fix: an angry bee keeps the player it stung in its chunk save
`Bee` implements `NeutralMob` and its `addAdditionalSaveData` ends with
`addPersistentAngerSaveData`, which writes `anger_end_time` and `angry_at`
(the UUID of the entity the bee is angry at). `readAdditionalSaveData` reads
both back with `readPersistentAngerSaveData`.

Horizon modelled the bee's target in `Bee.angry_at` and saved only
`anger_end_time`, so a bee that had stung a player came back after a chunk
reload with its anger timer still running but no `angry_at`:
`BeeBecomeAngryTargetGoal` asks for `Some(Who::Player)` there, saw none, and
the bee stopped attacking, while an anger at another mob was forgotten the
same way.

The save now writes `angry_at` as the target's UUID (the player's own, or the
level's answer for a mob id, nothing when there is no target), and the load
reads it back as the player or as a mob the level may not have loaded yet.
2026-11-09 05:18:34 +00:00
voidsip fb845f8486 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/10914-native-content-assets 2026-11-08 22:46:55 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 7ed014c216 HDR output: mark the start before building it, and retry on a new surface
Two fixes to the checked output switch.

The crash-recovery marker is written again before HDR output's pipelines are
built, as it was before the switch moved into output_apply. A driver that
crashes while they are built now leaves the marker, so the next start holds
HDR back instead of trying the same crashing start again.

A new window surface now always makes the settings apply again. Before, it did
so only when the surface offered another encoding. So an HDR output that had
failed and fallen back was not tried again on a new surface with the same
offer until something else changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:42:20 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 7cbced85fa HDR output: follow a switch between HDR10 and scRGB while HDR stays on
When the display or compositor stops offering HDR10 and offers scRGB (or the
other way round) while HDR is on, the game now switches the swapchain, its HDR
metadata and the HDR output pass to the new encoding. Before, it compared only
whether HDR was on, kept presenting the old encoding, and read the surface's
offer again only when the display's luminance or HDR report changed.

The renderer now reads what the surface offers at every due display poll and
after a new surface, even when the display reports the same. Each settings or
display change resolves one candidate output. A change of nits, UI brightness
or report alone updates the values without building anything. A new encoding
is built and the swapchain configured for it before the snapshot that the UI,
F3 and a pack's Frame read says so. If that fails, the output that was there
stays where it still presents, else SDR. The failed output is not tried again
each frame, only once the request, the capabilities or the surface change. The
mode setting is never changed. The switched HDR output keeps the player's
tuning (HDR Lighting, UI brightness, masks, tiers, calibration, debug view).

HDR off presents and draws exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:04:09 +01: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
voidsip 3690aa6119 Compare PR file growth against the tested merge base 2026-11-08 21:20:51 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 26d3eaaa9b HDR crash recovery: keep the big renderer and settings files from growing
Nothing changes for the player. The marker, the driver it names and the
once-per-start warning move from gfx.rs into gfx/hdr_start.rs, and Undo's
rule for the hold becomes Graphics::undone_to, so gfx.rs and graphics_ui.rs
end no longer than on the base (check_file_size, docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:11:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 20bbcc3d76 HDR crash recovery: pin that the HDR page says the hold as it opens
The graphics screen now reads the output of the renderer the options are
drawn for on every draw, kept outside the screen that opening resets. A
test pins that a window holding HDR output back shows it on the HDR page
as soon as the screen opens, with no setting applied: Calibrate blocked by
the hold, where the same window without it calibrates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:08:03 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 029c3e33d8 HDR crash recovery: a clean exit before HDR's first frame holds nothing
The marker that HDR output is starting is removed once HDR output presents
its first frame. A game closed cleanly before that, say while its window
was minimized, left it behind, and the next start held HDR output back on
a driver that never failed.

A clean exit now removes the window renderer's own marker. An exit after
an error, and a crash, still leave it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a07652630e HDR crash recovery: a system update tries HDR again too
The hold on a driver HDR output failed to start on named the adapter and
its driver as wgpu reports them. Metal names no driver, so on macOS, where
the graphics driver updates with the system, a hold was never released.

The driver's fingerprint now ends with the system's version, as crash
reports name it (Windows' build, macOS's version, Linux's distribution and
kernel), read once a run. A system update releases the hold everywhere,
which costs one more try of HDR output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 61a5dcc0db HDR crash recovery: Undo never brings the hold back
Undo on the graphics screen put back the whole of Horizon's graphics
settings, the hold on a driver HDR output failed to start on included. So
a held Auto changed to On, where HDR then worked on this driver, and
undone, came back held.

Undo now keeps the hold as it is, and drops it when it changes the HDR
mode, as any change of the mode does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 d40f83e06c HDR crash recovery: hold HDR back on the failing driver, keep the mode
A start that HDR output took down used to save HDR Off for good: one
crash while HDR started turned a player's HDR off forever, silently, even
after a driver update fixed it. With Auto the default (step 5a), that would
reach every player whose driver ever crashed once.

The marker now names the graphics driver it was written for: the backend,
the adapter's vendor and device IDs and name, and the driver's name and
version. A start that finds it keeps the mode (Auto or On) and saves that
driver as hdrFailedStart. A window on that driver starts SDR and stays SDR
while the key matches; the HDR page's HDR row says why, in one line, and
greys the brightness rows with the same line. Choosing any mode on the page
drops the key and tries HDR again. A window on another driver (an update,
another GPU) drops it too. The hold is logged once per start, with how to
clear it. A marker from before markers named a driver holds on the first
driver seen. Only a window writes the marker; offscreen renderers never
hold HDR back.

The hold is a capability of the window's output in the renderer-owned
resolver (OutputCapabilities::held), not a new global.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:12 +01: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
pehe1andCodex 1788fb7b84 Keep the combat fixture documentation with its operation
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:25:21 -03: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
pehe1andCodex c9592a87f6 Reset the blocking fixture player after each case
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:20:43 -03:00
pehe1andCodex 3b34446f84 Honor custom durability on blocking items
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:19:40 -03: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
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 7c2f9dc7f4 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/10914-native-content-assets 2026-11-08 18:38:08 -06:00
voidsip 0bd6a222a4 Merge main and preserve combined native content loading 2026-11-08 18:38:07 -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
TeiwazikandClaude Sonnet 5.5 62b593cb3d world_post: upscaler backend seam, TAA as the only backend
UpscaleFrame carries what a temporal upscaler needs (jittered colour,
depth, sizes, jitter in clip space and pixels, camera reprojection,
reset); UpscalerBackend turns it into the output picture. TAA's resolve
pass moves behind it unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 23:01:59 +01:00
Cheezer1656andClaude Code 7fe4eb3931 Native mods: the player's own inventory on a client
host.own_inventory() (native_own_inventory) gives the 36 slots of the
player's own inventory (0-8 hotbar, 9-35 main) with their components,
whichever menu is open. menu_stacks() is None for the window's own
inventory, and living.equipment only covers armor and the held item.

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-08 12:52:14 -07: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
gigamans442andCodex gpt-6.1-sol 2e88edc841 fix(shaders): preserve signed vertex and instance IDs
Convert GLSL ID builtins before expression typing; retain explicit unsigned conversions and validate all supported draw paths with original CPU controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 09:27:29 -05:00
MSedgeMC d12994e93c Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 13:59:19 +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
Cheezer1656 44e8e374b9 Merge upstream/main into upstream-gap/u-01-registry-name 2026-11-07 22:27:59 -07:00
Cheezer1656andClaude Code 218b421e16 Native mod API: registry names for numeric ids (native_registry_name)
A mod hearing packets through native_client_packet gets registry references
as numbers (a Stack's item and components, AddEntity's type, Sound,
LevelParticles) and could not name them. native_registry_name(registry, id)
resolves one against the ids the connection uses: the built-in registries,
those the server synced and a mod's own data components. On a server it asks
the hub's SyncData the same way.

Helper-only fragment registry_name.

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-07 22:10:47 -07:00
Horizon agent 83d8f3bf05 Merge remote-tracking branch 'origin/main' into m10243
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-08 02:05:48 +01: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
rison1337andCodex 06f7c5d593 Merge main and reuse its consistent dependency locks
Main restored the socket2 0.5 package after our temporary 0.6 workaround.
The synthetic PR merge retained an unused 0.5 entry with a 0.6 manifest,
so Cargo rejected both lockfiles under --locked. Reuse current main's
protocol manifest and both complete locks; the workaround is no longer
needed. PackedStorage's Clippy fix is already present on main as well.

Preserve the orb optimization and branch history with a normal merge.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 03:17:37 +05:00
Horizon agentandClaude Opus 5.5 79ee436bdf runner-gc: run every 30 minutes, not 6 hours
It skips busy runners, so a 6-hour tick rarely found the parity runners idle:
their probe builds reached 31 + 22 GB and the Mac mini fell to 4.6 GB free
(2026-11-07). The live plist uses StartInterval 1800.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:46:55 +01:00
Ludovic Marie 6f3a8699be Merge origin/main 2026-11-07 17:46:54 +01:00
Horizon agentandClaude Opus 5.5 6a2f968125 runner-gc: drop per-run harness scratch folders after 2 hours
917 mkdtemp folders (<harness>-<8 chars>) under two runners' .local filled the
Mac mini's disk on 2026-11-07; nothing reuses them after the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:21:55 +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
MSedgeMC 1925a4fa6f Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 13:10:23 +00:00
rison1337andCodex eb83c35515 Fix locked builds and the storage lint after merging main
Main's keepalive change requires socket2 0.5, but its lockfile references
that missing package while only socket2 0.6.5 remains. This prevents
locked builds before compilation after merging main.

Use the socket2 0.6 dependency already required by horizon-friends and
record that existing package for horizon-protocol in both game and launcher
lockfiles. Keep a single version
and retain the existing TCP keepalive behavior, checked by its socket test.

Use is_multiple_of for PackedStorage' nonzero per-word stride check, as
required by Clippy. The validated bit widths keep per_word between 2 and
64, so the predicate has the same behavior.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 17:09:52 +05:00
rison1337andCodex 98c7e923c3 Merge main after the flame TAA change landed
Reuse the accepted shared jitter-frustum helpers from main so this PR
contains only the independent experience-orb preparation optimization.
Preserve the existing branch history and maintainer changes.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 16:42:02 +05:00
Horizon agent 096a0b33ac Merge remote-tracking branch 'origin/main' into n/10088
# Conflicts:
#	crates/horizon-client/src/game.rs
#	tools/port_map/client2-009.tsv
2026-11-07 06:00:16 +01:00
GabrielNichols f4d97fff39 Include the Recipes fuel translations 2026-11-07 01:39:17 -03:00
Horizon agentandClaude Opus 5.5 056bb5d10a port_map: the deferred subtitles draw before the screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:41:27 +01:00
Horizon agentandClaude Opus 5.5 5e2520c6d2 Piston projectiles: leave the remote tick order as main has it
Main already runs tick_remote_entities before the client block entities
and piston pushes (Minecraft.tick: tickEntities, then tickBlockEntities),
so the reorder only moved the pistons ahead of the other block-entity
animations and left two comments on the wrong lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:18:01 +01:00
Horizon agentandClaude Opus 5.5 75d2a607e2 Arrows and tridents: release from the piston move only, as AbstractArrow does
Follow-up on top of the rework (the order and the release from the piston
move were right). Three things still differed from 26.3:
- AbstractArrow.tick stays in its in-ground branch the tick startFalling
  runs (inGroundTime++, applyEffectsFromBlocks); the arrow flew in that
  same tick. arrows.rs is back to main's branch, with the shared
  start_falling_velocity.
- Entity.move(PISTON) never touches xo: begin_tick and
  piston_pushed_this_tick kept the pre-push position across two ticks.
  The old position is taken at the tick's start again.
- On a slime block, moveCollidedEntities sets the velocity and then moves
  the entity, and that move is what takes a stuck arrow out
  (startFalling's random * 0.2 damping). The bounce no longer un-grounds
  it. The server trident's release_if_unblocked (no lastState check, no
  damping) is gone; the piston release covers the piston case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:04:40 +01:00
Horizon agentandClaude Opus 5.5 e1025cef1a Subtitles: an in-game screen's deferred subtitles draw before it
Screen.extractBackground runs Hud.extractDeferredSubtitles, so they sit under
the screen's slots and tooltips; drawing them after the screen put them over
both. F1 still leaves them with an in-game screen open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:58:23 +01:00
Horizon agentandClaude Opus 5.5 0a8dbbe152 Merge origin/main into #10088
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:58:17 +01:00
Horizon agent 9757924d07 Merge main into #10243 2026-11-07 01:53:03 +01: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
rison1337andCodex fe62d6c7db Merge main while retaining the orb-only optimization
Retain both the current depth-culling regression and the guarded-frustum
viewport-edge regression. Keep the shared billboard-frustum naming so
the separately submitted flame change can merge independently.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 04:04:15 +05:00
rison1337andCodex 4c764800c9 Split flame TAA culling out of the experience-orb PR
Keep this PR scoped to offscreen experience-orb preparation, including
its conservative TAA frustum. Restore the existing flame path and move
its independent optimization and measurements to a separate PR.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 04:02:52 +05:00
GabrielNichols d1b447525a Show ordered alternatives for Recipes furnace fuel 2026-11-06 19:16:57 -03:00
iwanethandCopilot 5894135cff Fix projectile piston compile errors
Co-authored-by: Copilot <[email protected]>
2026-11-06 21:11:32 +01:00
iwanethandCopilot 4a87dcdcde Allow gameplay-only piston helpers in client lib
Co-authored-by: Copilot <[email protected]>
2026-11-06 20:38:27 +01:00
iwanethandCopilot 565bc0c818 Move arrow fall helper to shared module
Co-authored-by: Copilot <[email protected]>
2026-11-06 20:26:26 +01:00
iwanethandCopilot acd93b5a0b Fix shared projectile helper path
Co-authored-by: Copilot <[email protected]>
2026-11-06 19:54:28 +01:00
iwanethandCopilot 38a3760258 Fix piston projectile tick parity
Co-authored-by: Copilot <[email protected]>
2026-11-06 18:56:31 +01: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
rison1337andCodex GPT-6 f4b7d61b80 Rerun CI after standalone rustc fixture timeouts
The verify runner exceeded the 60-second compiler timeout in the
native-mouse and split-window input fixtures. Both original tests pass
locally, as does the complete Python test suite. No files are changed.
A direct Actions rerun requires repository administrator permissions.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 19:52:34 +05:00
voidsip 58b69f8f71 Load combined native mod content with its exact build 2026-11-06 06:12:20 -06:00
rison1337andCodex GPT-6 f90bfe8f5b Share the jitter-guarded billboard frustum with entity flames
Keep offscreen flame preparation culling active under TAA using the same
input-pixel guard as orb sprites. Preserve ordinary model culling and
shader-pack fallbacks. Cover AABB acceptance at jittered viewport edges
and record separate flame FPS and image comparisons, including controls.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 16:12:44 +05:00
rison1337andCodex GPT-6 d227d13690 Merge main with completed UUID lookup build fixes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:34:37 +05:00
rison1337andCodex GPT-6 e310be62d4 Cull experience-orb sprites behind the camera, including TAA
Skip invisible orb light/color/quad preparation with conservative bounds.
Cover TAA projection jitter at the configured input resolution and retain
the existing shader-pack path. Include rendering measurements and checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:24:16 +05:00
iwanethandCodex 909d703b15 Merge upstream main and preserve projectile piston fixes
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:17:35 +01:00
iwanethandCodex 3fb08ac12f Keep projectile animation out of piston parity PR
Co-Authored-By: Codex <[email protected]>
2026-11-05 20:01:55 +01:00
MSedgeMC 887e94dff3 port_map: Hud.extractDeferredSubtitles is hud_layers' deferred draw
The row was absent; map-check: 150 rows, no problems.
2026-11-05 17:20:32 +00:00
MSedgeMC bb30c0b2cf test: the subtitles follow the HUD unless a screen keeps them
The four combinations of the HUD being shown and an in-game screen being
open, plus the screens that answer Screen.isInGameUi: the container screens
and the block editors yes, the pause menu, the chat and the death screen no,
and a book or a sign editor yes over Screen::Playing.
2026-11-05 17:20:24 +00:00
MSedgeMC ce0ce48661 fix: the subtitles draw over an in-game screen, and survive F1
Hud.extractRenderState asks Screen.isInGameUi twice. With no such screen the
subtitles draw in the HUD's own pass; with one they are deferred
(extractSubtitleOverlay(true) stores a Runnable that extractDeferredSubtitles
runs after the screen), and when F1 hides the HUD they are the one element
that still draws, as long as such a screen is open.

Screen.isInGameUi is false everywhere except AbstractContainerScreen and the
block, sign and book editors, so the rule covers the inventory, the creative
menu, the command block, structure block, jigsaw and test block editors, a
book the player reads and a sign being edited, and nothing else: not the pause
menu, the chat or the death screen.

Horizon drew the subtitles only inside the HUD's own pass, so they sat under
any open screen and vanished under F1. Now an in-game screen defers them, the
deferred draw lands after the screen and its tooltips and before the toasts,
and F1 alone still hides them.
2026-11-05 17:20:16 +00:00
voidsipandCodex 4b502a3c48 fix(prepare): release locks before closing descriptors
Explicitly unlock when a preparation finishes so a descriptor retained by
a forked child cannot extend the lock lifetime. Cover retained duplicates
while preserving the existing contention test.

Co-Authored-By: Codex <[email protected]>
2026-11-03 07:36:34 -06:00
iwanethandCopilot 41a885884c Merge latest origin/main into piston projectile fix
Co-authored-by: Copilot <[email protected]>
2026-11-01 23:39:54 +01:00
iwanethandCopilot 274ba45708 Fix projectile merge CI failures
Co-authored-by: Copilot <[email protected]>
2026-11-01 23:17:02 +01:00
IwaneTH 6f8a26a509 Merge branch 'main' into fix/piston-projectile-push 2026-11-01 22:55:52 +01:00
iwanethandCopilot b4ac2ce731 Merge origin/main into fix/piston-projectile-push
Co-authored-by: Copilot <[email protected]>
2026-11-01 22:30:00 +01:00
iwanethandCopilot d83f9d0504 Fix piston projectile flight and slime bounce
Align client piston updates with the server and release embedded arrows and tridents on slime bounce. Match vanilla projectile rotation interpolation.

Co-authored-by: Copilot <[email protected]>
2026-11-01 18:29:29 +01:00
iwanethandCopilot 64f566311b Smooth projectile flight orientation
Update remote arrows and tridents to ease their flight orientation like vanilla, and interpolate their angles during rendering so direction changes from piston pushes and collision updates are animated smoothly.

Co-authored-by: Copilot <[email protected]>
2026-10-31 23:30:53 +01:00
iwanethandCopilot f527f4af5d Fix tridents remaining suspended after block removal
Release stuck tridents when their embedded collision clears so gravity can resume, including after a piston push. Preserve projectile interpolation and synchronize pushed tridents.

Co-authored-by: Copilot <[email protected]>
2026-10-31 22:56:35 +01:00
iwanethandCopilot e011920b66 Push projectiles with moving pistons
Co-authored-by: Copilot <[email protected]>
2026-10-31 17:03:58 +01:00
943 changed files with 53344 additions and 19123 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)
+14 -11
View File
@@ -80,21 +80,24 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -217,17 +220,17 @@ jobs:
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
+7 -2
View File
@@ -168,9 +168,14 @@ jobs:
- name: ALSA and udev headers
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
if: ${{ !matrix.musl }}
run: cp -f "$BIN/horizon-official${{ matrix.exe }}" "$BIN/horizon-client${{ matrix.exe }}"
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
+3
View File
@@ -201,6 +201,9 @@ jobs:
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: Unrelated DLLs cannot intercept launcher or setup startup
if: runner.os == 'Windows'
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
+3 -3
View File
@@ -181,7 +181,7 @@ jobs:
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" "${musl[@]}" \
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -267,9 +267,9 @@ jobs:
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"${musl[@]}" \
${musl[@]+"${musl[@]}"} \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"${arm64[@]}" \
${arm64[@]+"${arm64[@]}"} \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
+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' || '' }}
+3
View File
@@ -44,6 +44,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Crates
@@ -79,6 +81,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-hooks`, `horizon-game`, `horizon-official`, `horizon-heartbeat` | The layer seam: the engine's hooks for crates above it, the game an executable starts, the official executable (shipped as `horizon-client`) that registers Horizon's in-game parts, the first of them ([docs/layers.md](docs/layers.md#how-a-horizon-crate-plugs-in)) |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
+10
View File
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
@@ -190,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
+66 -14
View File
@@ -1418,6 +1418,7 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1450,6 +1451,7 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1500,7 +1502,6 @@ version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
@@ -1528,10 +1529,8 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1587,13 +1586,12 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1652,6 +1650,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"
@@ -1669,6 +1676,7 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1677,6 +1685,15 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-mods",
"horizon-threads",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
@@ -1688,6 +1705,19 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-heartbeat"
version = "0.1.0"
dependencies = [
"horizon-platform",
"horizon-telemetry",
"log",
]
[[package]]
name = "horizon-hooks"
version = "0.1.0"
[[package]]
name = "horizon-items"
version = "0.1.0"
@@ -1768,6 +1798,29 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
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"
@@ -1782,7 +1835,13 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-core",
"horizon-hooks",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
"horizon-system",
"horizon-world",
]
[[package]]
@@ -1853,10 +1912,6 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1868,11 +1923,9 @@ dependencies = [
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-platform",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
@@ -1921,7 +1974,6 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+6 -1
View File
@@ -30,6 +30,12 @@ members = [
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-hooks",
"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",
@@ -39,7 +45,6 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
+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.
+3 -2
View File
@@ -129,8 +129,9 @@ ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
and `optical_flow.rs` and `interpolation.rs` follow the SDK's
`sdk/src/components/{opticalflow/ffx_opticalflow.cpp,frameinterpolation/ffx_frameinterpolation.cpp}`
(their passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
+1
View File
@@ -90,6 +90,7 @@ impl Report {
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
+7
View File
@@ -377,6 +377,13 @@ mod tests {
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
let combined = dir.join("machine_mod.hmod");
std::fs::copy(&hnmod, &combined).unwrap();
assert_eq!(
check::kind_of(&combined),
Some(horizon_mods::index::Kind::NativeMod)
);
assert!(check::check(&combined, check::kind_of(&combined).unwrap(), None, None).is_ok());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
+2 -2
View File
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review_with_revoked(&base, &head, &revoked, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
+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";
+21 -2
View File
@@ -26,18 +26,23 @@ pub struct AnimationMetadata {
pub enum ClipMode {
/// `day_time`: frame 0 at time 0, the last at the cycle's end, then again.
DayTime { cycle: i64 },
/// `screen_time`: the ticks the open screen has been open, over the cycle, held on the last
/// frame once it has run (it doesn't loop, measured on the real mod).
ScreenTime { cycle: i64 },
/// `weather`: frame 0 in clear weather, 1 in rain, 2 in a storm (the rain and thunder levels
/// added up), at most the last.
Weather,
/// A Polytone expression: its value's fraction is how far through the animation it is.
Expression(String),
/// `game_time`, `screen_time`, `season`: not driven yet; the animation runs on ticks.
/// `game_time`, `season`: not driven; the animation runs on ticks.
Unsupported(String),
}
/// The values [`ClipMode`]s read this frame.
pub struct ModeInputs<'a> {
pub day_time: i64,
/// The ticks the open screen has been open (0 without one).
pub screen_time: i64,
/// The rain level plus the thunder level, 0 to 2.
pub weather: f32,
/// An expression's value this frame, by its source.
@@ -116,7 +121,8 @@ impl AnimationMetadata {
match mode.as_str() {
"day_time" => ClipMode::DayTime { cycle },
"weather" => ClipMode::Weather,
"game_time" | "screen_time" | "season" => ClipMode::Unsupported(mode),
"screen_time" => ClipMode::ScreenTime { cycle },
"game_time" | "season" => ClipMode::Unsupported(mode),
_ => ClipMode::Expression(mode),
}
}),
@@ -239,6 +245,9 @@ impl AnimationClip {
pub fn sample_mode(&self, inputs: &ModeInputs) -> Option<AnimationSample> {
let fraction = match self.mode.as_ref()? {
ClipMode::DayTime { cycle } => day_fraction(inputs.day_time, *cycle),
ClipMode::ScreenTime { cycle } => {
(inputs.screen_time.max(0) as f64 / *cycle as f64).min(1.0 - f64::EPSILON)
}
ClipMode::Weather => {
let last = self.sequence.len() - 1;
let index = (inputs.weather.max(0.0) as usize).min(last);
@@ -288,6 +297,7 @@ mod mode_tests {
let expression = move |_: &str| Some(value);
clip.sample_mode(&ModeInputs {
day_time,
screen_time: day_time,
weather,
expression: &expression,
})
@@ -302,6 +312,13 @@ mod mode_tests {
.map(|t| frame(&day, t, 0.0, 0.0))
.into();
assert_eq!(frames, [0, 1, 2, 3, 1]);
// 26.3-6.8.6, four frames over 200 ticks: 40 ticks open show the first, and from ten
// seconds on the last, even after 27 (it doesn't loop).
let screen = clip(r#"{"frametime": 1, "mode": "screen_time", "time_cycle_duration": 200}"#);
let frames: Vec<u32> = [0, 40, 200, 360, 540]
.map(|t| frame(&screen, t, 0.0, 0.0))
.into();
assert_eq!(frames, [0, 0, 3, 3, 3]);
let value = clip(r#"{"frametime": 1, "mode": "0.6"}"#);
assert_eq!(frame(&value, 0, 0.0, 0.6), 2);
assert_eq!(frame(&value, 0, 0.0, 2.0), 0);
@@ -321,6 +338,7 @@ mod mode_tests {
clip(r#"{"frametime": 1}"#)
.sample_mode(&ModeInputs {
day_time: 0,
screen_time: 0,
weather: 0.0,
expression: &|_| None
})
@@ -330,6 +348,7 @@ mod mode_tests {
clip(r#"{"frametime": 1, "mode": "season"}"#)
.sample_mode(&ModeInputs {
day_time: 0,
screen_time: 0,
weather: 0.0,
expression: &|_| None
})
+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);
+2
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+3 -55
View File
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
@@ -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 [
(
+5 -5
View File
@@ -9,15 +9,15 @@ 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"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = ["dep:horizon-dualsense"]
controllers = []
[lints]
workspace = true
@@ -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 {
+223 -127
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)]
@@ -1523,9 +1678,8 @@ impl Rumble {
}
}
/// What a trigger feels like on a DualSense, from what its action is doing (docs/controllers.md
/// "Adaptive triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the
/// others may leave the firmware.
/// What a trigger feels like, from what its action is doing (docs/controllers.md "Adaptive
/// triggers"); the controller code turns it into a DualSense trigger effect.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TriggerFeel {
Free,
@@ -1544,66 +1698,6 @@ pub enum TriggerFeel {
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
Texture(f32),
}
#[cfg(feature = "controllers")]
impl TriggerFeel {
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
use horizon_dualsense::trigger;
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match self {
TriggerFeel::Free => horizon_dualsense::TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => horizon_dualsense::TRIGGER_OFF,
}
}
}
#[cfg(feature = "controllers")]
impl PadSettings {
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub fn trigger_effects(
&self,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [horizon_dualsense::TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|trigger| {
let actions = self.actions_of(trigger);
let feel = if !self.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
feel.effect(self.trigger_threshold)
})
}
}
/// What a DualSense's lightbar shows (`lightbar:`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lightbar {
@@ -1924,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]
@@ -2438,52 +2580,6 @@ bind_key.hotbar.2:north
assert_eq!(w.observe(Some(1.0)), Some(1.0));
}
#[cfg(feature = "controllers")]
#[test]
fn triggers_feel_their_action() {
use horizon_dualsense::{TRIGGER_OFF, trigger};
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = s.trigger_effects(TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
s.trigger_effects(TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(TriggerFeel::Click.effect(0.4), catch(7));
assert_eq!(TriggerFeel::Boost.effect(0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(TriggerFeel::Click.effect(0.7)[1..3], [0xa0, 0x00]);
assert_eq!(TriggerFeel::Click.effect(0.1), trigger::weapon(2, 3, 8));
assert_eq!(TriggerFeel::Brace.effect(0.4), trigger::feedback(2, 3));
assert_eq!(TriggerFeel::Locked.effect(0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
TriggerFeel::Texture(0.5).effect(0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
TriggerFeel::Texture(3.0).effect(0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
TriggerFeel::Texture(50.0).effect(0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(TriggerFeel::Texture(0.0).effect(0.4), TRIGGER_OFF);
assert_eq!(TriggerFeel::Texture(-1.0).effect(0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
s.trigger_effects(TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
#[test]
fn lightbar_colours() {
let full = Lightbar::Health.colour(1.0, "minecraft:overworld").unwrap();
@@ -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
+65 -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")]
@@ -670,6 +691,39 @@ pub fn with_horizon_icon(
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -706,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]
+7 -17
View File
@@ -18,10 +18,10 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -42,8 +42,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -72,17 +70,13 @@ 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); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
# 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" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
@@ -114,18 +108,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", "discord", "friends", "heartbeat"]
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", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# 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.
@@ -137,6 +125,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).
+8 -5
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
@@ -24,11 +26,9 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
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"
@@ -75,13 +75,16 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[lints]
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"] }
-7
View File
@@ -1,7 +0,0 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -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();
+60 -4
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| {
@@ -382,8 +415,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -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.
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
+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!(
+49 -1
View File
@@ -16,6 +16,45 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
pub fn filtered_message(
content: &str,
(kind, bits): &(i32, Vec<i64>),
) -> Option<serde_json::Value> {
match kind {
1 => return None,
2 => {}
_ => return Some(serde_json::Value::from(content)),
}
let masked = |i: usize| {
bits.get(i / 64)
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
};
let units: Vec<u16> = content.encode_utf16().collect();
let mut parts = Vec::new();
let mut start = 0;
while start < units.len() {
let filtered = masked(start);
let end = (start..units.len())
.find(|&i| masked(i) != filtered)
.unwrap_or(units.len());
parts.push(if filtered {
serde_json::json!({
"text": "#".repeat(end - start),
"color": "dark_gray",
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
})
} else {
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
});
start = end;
}
Some(serde_json::json!({"text": "", "extra": parts}))
}
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
@@ -1935,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
@@ -2047,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;
@@ -1026,3 +1026,72 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
/// into a dark grey `#` whose hover reads `chat.filtered`.
#[test]
fn a_server_filter_mask_hides_or_masks_the_message() {
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
let shown = |content: &str, mask: (i32, Vec<i64>)| {
filtered_message(content, &mask).map(|v| text(&v).plain())
};
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
assert_eq!(shown("hello", (1, vec![])), None);
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
assert_eq!(
shown("hello", (2, vec![0b1_0001])),
Some("#ell#".to_owned())
);
// An emoji is two UTF-16 units, so two bits and two `#`.
assert_eq!(
shown("a\u{1F600}b", (2, vec![0b0110])),
Some("a##b".to_owned())
);
// Bit 65 is in the second word.
let long = "x".repeat(70);
let mut expected = long.clone();
expected.replace_range(65..66, "#");
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
let hover = hash.style.hover.as_ref().unwrap();
assert_eq!(hover.spans[0].text, "chat.filtered");
assert!(
masked
.spans
.iter()
.filter(|s| s.text != "##")
.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);
}
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
}
out.push((
"UpdateLastExecution",
+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",
+6 -1
View File
@@ -43,6 +43,11 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
}
}
/// The native mod call this thread is currently inside, if any.
pub fn current_native_mod() -> Option<NativeModCall> {
IN_NATIVE_MOD.with(Cell::get)
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
@@ -244,7 +249,7 @@ fn arch_label(build: &str, machine: &str) -> String {
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
pub(crate) fn operating_system() -> String {
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
+76 -204
View File
@@ -1,27 +1,34 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
use std::path::Path;
use std::sync::Arc;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
use glam::Mat4;
/// A layer's texture, RGBA8.
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -34,62 +41,44 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -97,147 +86,30 @@ impl CustomSky {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
s.world = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
}
@@ -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.
@@ -305,6 +307,9 @@ pub(crate) struct Colormap {
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
@@ -323,6 +328,18 @@ enum Axis {
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
@@ -373,6 +390,23 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub(crate) fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
@@ -424,9 +458,11 @@ impl Colormap {
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
@@ -525,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 {
@@ -562,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);
}
}
+12 -26
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,
@@ -482,13 +477,16 @@ impl Extras {
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file.
/// setup gains no file. The settings stores of this file follow these lines
/// (`horizon_hooks::settings`).
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
if *self == Self::default() && !path.exists() {
let mut text = self.serialize();
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
if *self == Self::default() && stores_default && !path.exists() {
return;
}
let _ = fs::write(path, self.serialize());
let _ = fs::write(path, text);
}
}
@@ -610,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.
@@ -688,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",
@@ -721,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",
@@ -765,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",
@@ -789,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;
}
})
@@ -841,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."
}
@@ -924,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,
@@ -983,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,
@@ -1101,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();
@@ -1140,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));
+290 -8
View File
@@ -1,5 +1,6 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
use crate::{
extras::{self, Item},
lang,
@@ -7,6 +8,7 @@ use crate::{
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
use horizon_hooks::pages::{self as added, Act, Label};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
@@ -31,6 +33,125 @@ pub(crate) enum ExtraRow {
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
Added(usize, usize),
}
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered, all places.
fn pages() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// The sections of this page, by their index among the registered pages.
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
let pages = pages().iter().copied().enumerate();
pages.filter(|(_, page)| page.place() == added::Place::Extras)
}
fn label_text(label: Label) -> String {
match label {
Label::Key(key) => lang::get(key),
Label::Value(text) => text,
}
}
/// The page's group named `name`, made at the end if it has none.
fn group_named<'a>(
groups: &'a mut Vec<Group<ExtraRow>>,
name: &'static str,
) -> &'a mut Group<ExtraRow> {
let at = groups.iter().position(|group| group.name == name);
let at = at.unwrap_or_else(|| {
groups.push(Group {
name,
rows: Vec::new(),
});
groups.len() - 1
});
&mut groups[at]
}
/// The sections' rows, in the groups their titles and headings name.
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
for (page, section) in sections() {
let mut rows = Vec::new();
section.rows(&mut rows);
let mut name = section.title();
for (r, row) in rows.into_iter().enumerate() {
let id = ExtraRow::Added(page, r);
let row = match row {
added::Row::Heading(key) => {
name = key;
continue;
}
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
added::Row::Toggle { label, on } => {
Row::new(id, lang::get(label), Value::Switch(on))
}
added::Row::Cycle { label, value } => {
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
}
added::Row::Slider { label, value, text } => {
let position = value.clamp(0.0, 1.0);
let text = label_text(text);
let value = Value::Slider {
position,
steps: None,
text,
};
Row::new(id, lang::get(label), value)
}
added::Row::Button {
label,
active: true,
..
} => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
}
// A row kind newer than this engine.
_ => continue,
};
let row = match section.tooltip(r) {
Some(about) => row.about(label_text(about)),
None => row,
};
group_named(groups, name).rows.push(row);
}
}
}
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
let Some(section) = pages().get(page) else {
return Vec::new();
};
section.act(r, act);
vec![Output::Event(Event::Changed)]
}
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
fn steps(page: usize, r: usize) -> bool {
let mut rows = Vec::new();
if let Some(section) = pages().get(page) {
section.rows(&mut rows);
}
matches!(
rows.get(r),
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
)
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
@@ -85,9 +206,14 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and whether cosmetics are offered.
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -107,6 +233,7 @@ impl Screen for ExtrasScreen {
}
}
}
add_sections(&mut groups);
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
@@ -125,16 +252,29 @@ impl Screen for ExtrasScreen {
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
ExtraRow::Added(page, r) => {
act(page, r, if back { Act::PressBack } else { Act::Press })
}
}
}
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
if let ExtraRow::Added(page, r) = row {
act(page, r, Act::Slide(position));
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
ExtraRow::Added(page, r) if steps(page, r) => {
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
}
_ => None,
}
}
}
@@ -292,4 +432,146 @@ mod tests {
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
/// number that steps either way, a note and a volume.
struct Section {
on: std::sync::Mutex<bool>,
step: std::sync::Mutex<i32>,
volume: std::sync::Mutex<f32>,
}
impl added::Page for Section {
fn id(&self) -> &'static str {
"test.section"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> added::Place {
added::Place::Extras
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Toggle {
label: "test.section.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Heading("test.section.heading"));
rows.push(added::Row::Cycle {
label: "test.section.step",
value: Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Note("a note".into()));
rows.push(added::Row::Slider {
label: "test.section.volume",
value: *self.volume.lock().unwrap(),
text: Label::Value(String::new()),
});
}
fn act(&self, row: usize, act: Act) {
match (row, act) {
(0, Act::Press | Act::PressBack) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, Act::Press) => *self.step.lock().unwrap() += 1,
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
_ => {}
}
}
}
/// A page for Horizon Settings: never on Extras.
struct Elsewhere;
impl added::Page for Elsewhere {
fn id(&self) -> &'static str {
"test.elsewhere"
}
fn title(&self) -> &'static str {
"test.elsewhere"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::button("test.elsewhere.button"));
}
fn act(&self, _: usize, _: Act) {}
}
fn with_section() -> &'static Section {
let section: &'static Section = Box::leak(Box::new(Section {
on: false.into(),
step: 0.into(),
volume: 0.5.into(),
}));
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
&Elsewhere as &dyn added::Page,
section as &dyn added::Page,
]));
TEST_PAGES.set(Some(pages));
section
}
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
/// starts a group after the page's; a page for elsewhere adds nothing.
#[test]
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
with_section();
let s = Settings::default();
let sheets = ExtrasScreen.sheets(&s);
let groups = &sheets[0].groups;
let ids = |name: &str| -> Vec<ExtraRow> {
let group = groups.iter().find(|g| g.name == name).unwrap();
group.rows.iter().map(|r| r.id).collect()
};
let multiplayer = ids("horizon.extras.header.multiplayer");
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
assert_eq!(groups.last().unwrap().name, "test.section.heading");
let own = [
ExtraRow::Added(1, 2),
ExtraRow::Added(1, 3),
ExtraRow::Added(1, 4),
];
assert_eq!(ids("test.section.heading"), own);
let all = rows_of(&s);
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
}
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
/// note takes no click; the rows show what the section holds after each.
#[test]
fn a_sections_rows_act_on_it() {
let section = with_section();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let switch = ExtraRow::Added(1, 0);
let at = shell.point_at(&s, SIZE, switch);
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(*section.on.lock().unwrap());
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
assert_eq!(value(&s, switch), Value::Switch(true));
let step = ExtraRow::Added(1, 2);
let mut screen = ExtrasScreen;
screen.press(&mut s, step, true);
assert_eq!(value(&s, step), Value::Choice("-1".into()));
assert!(screen.wheel_step(&mut s, step, false).is_some());
assert_eq!(*section.step.lock().unwrap(), 0);
assert_eq!(
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
None
);
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
assert!(
shell
.click(&mut s, note, SIZE, false, false, false)
.is_empty()
);
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
assert_eq!(*section.volume.lock().unwrap(), 0.25);
}
}
+57 -4
View File
@@ -231,8 +231,10 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
/// 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]
@@ -581,7 +602,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = horizon_polytone::colors::Environment {
let environment = crate::pack_palette::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -611,9 +632,41 @@ 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: horizon_polytone::colors::Environment,
environment: crate::pack_palette::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+152 -404
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.
@@ -179,7 +184,7 @@ fn packed_wanted() -> bool {
std::env::var("HORIZON_PACKED_QUADS").as_deref() != Ok("0")
}
mod animate;
pub(crate) mod animate;
mod arena;
pub mod capture;
pub mod compositor;
@@ -194,12 +199,14 @@ mod frame_gen;
mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
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;
@@ -215,6 +222,7 @@ mod occlusion;
mod oit;
mod oit_features;
pub(crate) mod output;
mod output_apply;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
@@ -234,9 +242,11 @@ 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};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -248,6 +258,7 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -359,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);
@@ -724,62 +679,6 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -1136,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
@@ -1223,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>,
@@ -1327,7 +1231,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -1526,6 +1430,10 @@ pub struct Renderer {
emission_stats: hdr_stats::EmissionStats,
/// What this renderer's destination offers and its device draws.
output_caps: output::OutputCapabilities,
/// An output that failed to apply, not tried again until something it needs changes.
output_rejected: Option<output_apply::Rejected>,
/// Since when Auto keeps HDR on through the display's loss of its report.
auto_off_since: Option<std::time::Instant>,
/// The output applied (`update_output`), with the frame every pipeline that draws it
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
@@ -1538,6 +1446,8 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The window's graphics driver (`hdr_start`); `None` offscreen.
hdr_driver: Option<String>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
@@ -1624,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,
@@ -2985,11 +2898,13 @@ impl Renderer {
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
let hdr_driver = surface.is_some().then(|| output::window_driver(&info));
let output_caps = output::OutputCapabilities {
hdr: hdr_output,
wide_frame: wide_ok,
float_frame: float_ok,
hud_layer: coverage_ok,
held: output::held(settings, hdr_driver.as_deref()),
};
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
@@ -4239,16 +4154,13 @@ impl Renderer {
CRUMBLING_BLEND,
false,
);
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth
// test without writes. The quads lie exactly on the block tops like
// vanilla's, facing +Y so looking up from below culls them, but
// terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth
// and the shadow's rim z-fights with the block top (speckle that
// re-rolls as the camera drifts a subpixel). A constant bias of 128
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth test without writes.
// The quads lie exactly on the block tops like vanilla's, facing +Y so looking up from
// below culls them, but terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth and the shadow's rim
// z-fights with the block top (speckle that re-rolls as the camera drifts a subpixel). A
// constant bias of 128 ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto the feet.
let shadow_blob_pipeline = entity_pipeline_with(
"Entity shadows",
Some(wgpu::Face::Back),
@@ -4992,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.
@@ -5124,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,
@@ -5160,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,
@@ -5346,10 +5260,13 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
auto_off_since: None,
// Nothing is presented as HDR yet: `update_output` applies the settings.
output: output::resolve_output(settings, output_caps, display, frame, false),
frame_kept_logged: false,
hdr_marker: None,
hdr_driver,
pack_post: None,
pack_loading: None,
native: None,
@@ -5392,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,
@@ -5507,18 +5426,18 @@ impl Renderer {
))
});
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
let format = self.output.frame.format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
@@ -5527,8 +5446,8 @@ impl Renderer {
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
@@ -5631,6 +5550,7 @@ impl Renderer {
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(Arc::new(surface));
self.surface_recreated();
Ok(())
}
/// The world's depth, which occlusion culling's test and post effects may read.
@@ -6790,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;
}
@@ -6807,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)
@@ -6816,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.
@@ -7083,21 +7022,11 @@ impl Renderer {
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// 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`).
@@ -7149,106 +7078,11 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.output_caps,
self.reading,
self.caps_for(settings),
output_apply::graced(self.auto_off_since, self.reading, &self.output),
self.config.format,
)
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.refresh_hdr_offer();
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now: Vulkan's offered formats can change with desktop
/// HDR or the window's output.
fn refresh_hdr_offer(&mut self) {
if let Some(surface) = &self.surface {
self.output_caps.hdr = hdr::output(&surface.get_capabilities(&self.adapter));
}
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
let next = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let [paper_white, peak, black] = next.nits;
if next.encoding.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if next.encoding.is_some() {
if self.surface.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
}
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let frame = self.output.frame;
self.hdr = next.encoding.map(|output| {
hdr::Hdr::new(
&self.device,
&self.queue,
frame.format,
output,
frame.hud_layer,
)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
hdr.black = black;
self.send_hdr_metadata();
}
// An output of another encoding than the one presented doesn't replace it yet, only
// Off does: the snapshot says what is presented.
self.output = output::EffectiveOutput {
encoding: self.hdr.as_ref().map(|hdr| hdr.output),
..next
};
}
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
/// masks. Only with HDR output on: without it the frame's alpha is never read.
pub fn measure_emission(&mut self) {
@@ -7258,24 +7092,6 @@ impl Renderer {
.measure(&self.device, &self.queue, &views, hdr.masks);
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let Some(now) = watch.changed(self.reading, self.surface.as_deref(), &self.adapter) else {
return false;
};
self.refresh_hdr_offer();
self.reading = now;
true
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
@@ -7381,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,
@@ -7551,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,
@@ -7716,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(),
}
});
@@ -8143,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()));
@@ -8463,8 +8284,12 @@ impl Renderer {
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
self.hud
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
self.hud.animate(
&mut self.uploads,
&mut encoder,
frame.ticks,
&self.animations,
);
}
self.update_lightmap(
&mut encoder,
@@ -8483,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;
}
@@ -9402,13 +9225,11 @@ impl Renderer {
}
// Near water and distant water share one back-to-front draw order.
if self.translucent_draws.len() > section_translucent {
// Distant water goes after the near chunks', and replaced tiles'
// after all: each is a hair behind the one before, so depth leaves
// it only where that one has none. Back to front together, a far
// tile's water under a near chunk's (one fading in, or brought
// into view by a longer render distance) blended twice and looked
// opaque. The cost: far water just behind near water shows no
// surface of its own there.
// Distant water goes after the near chunks', and replaced tiles' after all: each is a
// hair behind the one before, so depth leaves it only where that one has none. Back to
// front together, a far tile's water under a near chunk's (one fading in, or brought
// into view by a longer render distance) blended twice and looked opaque. The cost: far
// water just behind near water shows no surface of its own there.
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
None => 0,
Some(tile) if visible_lods[tile].old => 2,
@@ -10228,7 +10049,11 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -12206,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).
@@ -12826,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 {
@@ -13826,6 +13571,7 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13833,6 +13579,7 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13841,6 +13588,7 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15399,6 +15147,10 @@ struct DisplayWatch {
}
impl DisplayReading {
/// What wgpu reads from the OS for `surface`'s display; nothing without one.
fn read(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Self {
surface.map_or_else(Self::default, |s| Self::os(&s.display_hdr_info(adapter)))
}
fn os(info: &wgpu::DisplayHdrInfo) -> Self {
#[cfg(target_os = "macos")]
let hdr = macos_hdr(info);
@@ -15439,9 +15191,7 @@ impl DisplayWatch {
return (watch, wayland_reading(now));
}
let _ = window;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
let now = DisplayReading::read(surface, adapter);
log_os_reported(now.luminance);
let watch = Self {
#[cfg(target_os = "linux")]
@@ -15450,30 +15200,28 @@ impl DisplayWatch {
};
(watch, now)
}
/// What the system reports, if that changed from `last`.
fn changed(
/// What the system reports at each poll that is due at `time` (`read`; on Wayland, at each
/// event), whether or not it is `last` again: the surface's capabilities are read with it.
fn poll(
&mut self,
last: DisplayReading,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
time: std::time::Instant,
read: impl FnOnce() -> DisplayReading,
) -> Option<DisplayReading> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(wayland_reading);
}
let next = self.polled.as_mut()?;
let time = std::time::Instant::now();
if time < *next {
return None;
}
*next = time + DISPLAY_POLL;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
(now != last).then(|| {
let now = read();
if now != last {
log_os_reported(now.luminance);
now
})
}
Some(now)
}
}
@@ -15531,11 +15279,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
/// 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`, 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;
@@ -15547,6 +15293,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15582,6 +15329,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -15830,11 +15578,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_render_pipeline::Shadow {
let shadow = horizon_shaders::Shadow {
distance: 128.0,
near,
far,
..horizon_render_pipeline::Shadow::default()
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -64,7 +64,7 @@ enum Writer {
},
}
pub(super) struct SpriteAnimator {
pub(crate) struct SpriteAnimator {
sprites: Vec<Sprite>,
groups: Vec<Group>,
writer: Writer,
@@ -86,6 +86,9 @@ struct AnimationModes {
day_time: i64,
weather: f32,
values: std::collections::HashMap<String, f64>,
/// The open screen (an identity the game picks) and the ticks it has been open.
screen: Option<u64>,
screen_time: i64,
}
impl AnimationModes {
@@ -116,8 +119,15 @@ impl super::Renderer {
day_time: i64,
[rain, thunder]: [f32; 2],
frame: Option<&crate::environment::Frame>,
screen: Option<u64>,
) {
let modes = &mut self.animations.modes;
// `screen_time`: a tick more while the same screen stays open, from 0 on a new one.
modes.screen_time = match (screen, modes.screen) {
(Some(open), Some(was)) if open == was => modes.screen_time + 1,
_ => 0,
};
modes.screen = screen;
modes.day_time = day_time;
modes.weather = rain + thunder;
modes.values.clear();
@@ -133,6 +143,16 @@ impl super::Renderer {
}
impl SpriteAnimator {
/// What the modes read this frame, for the GUI atlas's sprites (their expressions aren't
/// compiled: those run on ticks).
pub(crate) fn mode_inputs(&self) -> horizon_assets::animation::ModeInputs<'static> {
horizon_assets::animation::ModeInputs {
day_time: self.modes.day_time,
screen_time: self.modes.screen_time,
weather: self.modes.weather,
expression: &|_| None,
}
}
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
/// wide; `animations` hold their frames at that size. `compute`: whether
/// the device says it runs compute shaders writing storage textures.
@@ -361,6 +381,7 @@ impl SpriteAnimator {
let expression = |source: &str| values.get(source).copied();
let inputs = horizon_assets::animation::ModeInputs {
day_time: self.modes.day_time,
screen_time: self.modes.screen_time,
weather: self.modes.weather,
expression: &expression,
};
@@ -2,8 +2,8 @@
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
@@ -425,7 +425,7 @@ mod tests {
eprintln!("no GPU: skipping");
return;
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
@@ -441,7 +441,7 @@ mod tests {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
@@ -470,11 +470,11 @@ mod tests {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
let vanilla = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
@@ -3,8 +3,8 @@
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
@@ -325,7 +325,7 @@ mod tests {
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
layout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = ColorModulator;\n}\n";
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
@@ -1,12 +1,11 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
use crate::custom_sky::{Blend, Draw, SkyLayers};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -256,10 +325,15 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.images()
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -413,6 +487,38 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
@@ -1,3 +1,25 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// 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.
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
+163 -8
View File
@@ -185,13 +185,13 @@ pub struct Hdr {
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
pub black: f32,
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
/// The HUD and menus' brightness, as a share of paper white: above 1 brighter than it.
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
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.
@@ -419,6 +419,26 @@ impl Hdr {
}
}
/// The same HDR output presenting `output` instead (`output_apply`): the same frame and
/// HUD layer, and the player's tuning as it is (HDR Lighting, the world's lighting, the UI
/// brightness, the masks and tiers, the debug view, calibration and the luminance). Its
/// targets are made again on the next frame.
pub fn retargeted(&self, device: &wgpu::Device, queue: &wgpu::Queue, output: Output) -> Self {
Self {
debug: self.debug,
paper_white: self.paper_white,
peak: self.peak,
black: self.black,
tiers: self.tiers,
ui: self.ui,
masks: self.masks,
lighting: self.lighting,
look: self.look,
calibration: self.calibration,
..Self::new(device, queue, self.format, output, self.layered.is_some())
}
}
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
@@ -936,7 +956,7 @@ impl Hdr {
f32::from(u8::from(pq)),
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
params[6] = self.ui;
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
@@ -981,12 +1001,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
@@ -1541,6 +1563,93 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// HDR output switched from HDR10 to scRGB and back (`output_apply`): the player's tuning
/// stays, each swapchain gets the same light in its own encoding, and the HDR10 capture
/// and the HDR10 picture after the switch back are the ones before it.
#[test]
fn a_switched_encoding_keeps_the_tuning_and_shows_the_same_light() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR encoding switch GPU skipped: no adapter");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
nits: 700.0,
band: 0.6,
});
let tuning = |h: &Hdr| {
(
[h.paper_white, h.peak, h.black, h.ui],
(h.look, h.lighting, h.debug, h.layered.is_some(), h.format),
(h.masks, h.tiers, h.calibration),
)
};
let switched = hdr.retargeted(&device, &queue, Output::ScRgb);
assert_eq!(switched.output, Output::ScRgb);
assert_eq!(tuning(&switched), tuning(&hdr));
hdr.calibration = None;
hdr.debug = false;
// The swapchain's first texel (`capture`: the HDR10 screenshot's) for light 2 times
// paper white, grey, as the layered frame stores it.
let shot = |hdr: &mut Hdr, capture: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let g = 2f64.powf(1.0 / 2.2);
fill(&mut encoder, &frame, [g, g, g, 1.0]);
hdr.begin(&queue, true);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let target = if capture {
hdr.capture(&device, &queue, &mut encoder, true).unwrap()
} else {
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: &[],
})
};
if !capture {
hdr.present(&queue, &mut encoder, &target, true, None);
}
texel(&device, &queue, encoder, &target)
};
// BT.2100's PQ EOTF: a 10-bit code's nits.
let pq_nits = |code: f32| {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = (code / 1023.0f32).powf(1.0 / m2);
((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) * 10000.0
};
let pq = shot(&mut hdr, false);
let pq_capture = shot(&mut hdr, true);
let mut scrgb = hdr.retargeted(&device, &queue, Output::ScRgb);
let linear = shot(&mut scrgb, false);
assert_eq!(shot(&mut scrgb, true), pq_capture);
// Two times paper white in both: 500 nits, as HDR10's code and as scRGB's 80-nit units.
let near = |got: f32| (got - 500.0).abs() <= 0.015 * 500.0;
assert!(
near(pq_nits(pq[0])) && pq[..3].iter().all(|&c| c == pq[0]),
"{pq:?}"
);
assert!(near(linear[0] * 80.0), "{linear:?}");
let mut back = scrgb.retargeted(&device, &queue, Output::Pq);
assert_eq!(
(shot(&mut back, false), shot(&mut back, true)),
(pq, pq_capture)
);
}
/// ITU-R BT.2390's black level lift on grey light, in HDR10 and in scRGB, against values
/// worked out apart from the shader, in f64 from SMPTE ST 2084: E3 = E2 + b (1 - E2)^4 on
/// the PQ signal, for a source from 0 to the peak and b the black level's share of it.
@@ -1799,9 +1908,11 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
// Covered: the HUD as drawn, at UI Brightness.
(2.0, 0.3, 1.0, 1.0, 0.3),
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
// UI Brightness above paper white: brighter than drawn.
(2.0, 0.3, 1.0, 2.0, 0.3 * 2f32.powf(1.0 / 2.2)),
// Added over the world (the text highlight), which it doesn't hide.
(0.6, 0.8, 0.0, 1.0, 0.8),
// At or below white, at 100%: the HUD's picture as drawn.
// At or below white, at paper white: the HUD's picture as drawn.
(0.7, 0.45, 0.5, 1.0, 0.45),
] {
hdr.ui = ui;
@@ -1821,6 +1932,50 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
}
}
/// On the frame without the HUD's layer, the HUD at UI Brightness reaches its nits, and
/// above the peak stops there, as on the layered frame.
#[test]
fn the_hud_at_ui_brightness_stops_at_the_peak() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba8Unorm, Output::ScRgb, false, None);
(hdr.paper_white, hdr.peak) = (200.0, 300.0);
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Output::ScRgb.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// UI Brightness (a share of paper white) and the white HUD's nits.
for (ui, want) in [(0.5, 100.0), (1.25, 250.0), (2.0, 300.0)] {
hdr.ui = ui;
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [0.2, 0.2, 0.2, 1.0]);
hdr.keep_world(&mut encoder, None);
// Opaque white HUD over the world: alpha 1 is full coverage.
fill(&mut encoder, &frame, [1.0; 4]);
hdr.begin(&queue, true);
hdr.present(&queue, &mut encoder, &target, true, None);
let got = texel(&device, &queue, encoder, &target);
for c in &got[..3] {
assert!(
(c * 80.0 - want).abs() < 0.5,
"UI {ui}: {got:?}, not {want} nits"
);
}
}
}
#[test]
fn the_menu_blur_keeps_the_light_it_blurs() {
let Some((device, queue)) = gpu() else {
@@ -0,0 +1,65 @@
//! A window's HDR output start and what a failed one leaves (`graphics::HDR_MARKER`). Turning
//! HDR output on writes a marker naming the window's graphics driver (`output::window_driver`);
//! the first frame presented, turning HDR output off and a clean exit remove it. A start that
//! finds it holds HDR output back on that driver (`output::held`) until a mode is chosen on the
//! HDR page or the driver changes. Offscreen renderers have no driver: they never write the
//! marker or hold HDR output back.
use super::{Renderer, output};
impl Renderer {
/// What this renderer's destination offers for `settings`: HDR output held back on a window
/// whose graphics driver a start failed on (`output::held`).
pub(super) fn caps_for(
&self,
settings: &crate::graphics::Graphics,
) -> output::OutputCapabilities {
output::OutputCapabilities {
held: output::held(settings, self.hdr_driver.as_deref()),
..self.output_caps
}
}
/// Takes the hold `settings` put on this window's driver into the capabilities, and says it
/// once per start.
pub(super) fn hold_hdr_back(&mut self, settings: &crate::graphics::Graphics) {
self.output_caps = self.caps_for(settings);
if self.output_caps.held
&& let Some(driver) = &self.hdr_driver
{
static LOGGED: std::sync::Once = std::sync::Once::new();
LOGGED.call_once(|| {
log::warn!(
"Horizon renderer: HDR output is held back: the last start with this graphics driver ({driver}) ended before HDR output showed a frame. Choose an HDR mode on the HDR page to try again (or remove hdrFailedStart from {})",
crate::graphics::FILE
)
});
}
}
/// Writes the marker at `marker` as a window's HDR output turns on (`on`), and removes this
/// renderer's marker as it turns off.
pub(super) fn mark_hdr_start(&mut self, on: bool, marker: &std::path::Path) {
if !on {
self.drop_hdr_marker();
} else if self.surface.is_some()
&& let Some(driver) = &self.hdr_driver
{
let _ = std::fs::write(marker, crate::graphics::hdr_marker_text(driver));
self.hdr_marker = Some(marker.to_owned());
}
}
/// The graphics driver this window's HDR output starts on (`output::window_driver`);
/// `None` offscreen.
pub fn hdr_driver(&self) -> Option<&str> {
self.hdr_driver.as_deref()
}
/// Removes this renderer's marker of an HDR output that hasn't presented a frame yet, for a
/// clean exit: only a crash may leave it.
pub fn drop_hdr_marker(&mut self) {
if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
}
}
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_render_pipeline::Shadow>,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -2677,7 +2677,8 @@ file = \"b.wgsl\"
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
/// alone, held to the headroom. In SDR Horizon's is a clip at white. An HDR output switched
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
@@ -2687,8 +2688,9 @@ file = \"b.wgsl\"
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR, the
// output switched).
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
@@ -2746,6 +2748,11 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
display = display.retargeted(&device, &queue, Pq);
}
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -2813,14 +2820,16 @@ file = \"b.wgsl\"
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
for switched in [false, true] {
let horizon = shown(false, true, switched);
assert!(near(horizon, 4.82043), "{switched}: {horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true, switched);
assert!(near(pack, headroom), "{switched}: {pack}");
}
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
let sdr = shown(pack_curve, false, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
@@ -11,9 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
/// The hooks' trait and dispatch, made from `native-hooks.json` by `cargo run -p xtask -- mod-api`;
/// the game's build checks that the file is current.
mod generated;
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
@@ -0,0 +1,71 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API 364.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
///
/// Every mod gets `event.warp` in order and may add, change or remove warp.
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
/// What [`RenderHooks::world_warp`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldWarpEvent<'a> {
pub warp: &'a mut WorldWarp,
}
pub fn dispatch_world_warp(mods: &[&dyn RenderHooks], warp: &mut WorldWarp) {
let mut event = WorldWarpEvent { warp };
for mod_hook in mods {
mod_hook.world_warp(&mut event);
}
}
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -0,0 +1,59 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
+220 -6
View File
@@ -18,6 +18,43 @@ pub(crate) struct OutputCapabilities {
pub float_frame: bool,
/// The device keeps the HUD on a layer of its own (`hud_coverage::supported`).
pub hud_layer: bool,
/// HDR output is held back: the last start on this window's graphics driver ended before
/// HDR output showed a frame ([`held`]). The mode stays; the output is SDR.
pub held: bool,
}
/// Whether HDR output is held back on a renderer whose window's graphics driver is `driver`
/// (`None` offscreen, which never holds it back: only a window writes the marker).
pub(crate) fn held(settings: &Graphics, driver: Option<&str>) -> bool {
driver.is_some_and(|driver| settings.hdr_held(driver))
}
/// The graphics driver this window's HDR output starts on ([`driver_fingerprint`]), with the
/// system's version as crash reports name it (`crash::operating_system`), read once a run.
pub(crate) fn window_driver(info: &wgpu::AdapterInfo) -> String {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
driver_fingerprint(info, OS.get_or_init(crate::crash::operating_system))
}
/// The graphics driver a window's HDR output starts on, in one line: the backend, the
/// adapter's vendor and device IDs and its name, the driver's name and its version, and the
/// system's version `os`. Another GPU, a driver update or a system update reads differently:
/// Metal names no driver, so on macOS the driver updates with the system.
pub fn driver_fingerprint(info: &wgpu::AdapterInfo, os: &str) -> String {
let adapter = format!(
"{:?} {:04x}:{:04x} {}",
info.backend, info.vendor, info.device, info.name
);
[adapter.as_str(), &info.driver, &info.driver_info, os]
.map(|field| {
field
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.trim()
.to_owned()
})
.join("; ")
}
/// What the system last reported for a renderer's display: its luminance, and whether it
@@ -97,14 +134,19 @@ pub struct EffectiveOutput {
pub(crate) reported: Option<Reported>,
/// The device draws the 16-bit frame (Smooth Gradients).
pub(crate) wide_frame_supported: bool,
/// HDR output is held back on this window's graphics driver (`OutputCapabilities::held`).
pub(crate) held: bool,
/// The HDR output presented; `None` is SDR.
pub(crate) encoding: Option<Output>,
pub(crate) frame: FrameConfiguration,
/// Paper white, peak and black level in nits, as HDR output's last pass and its
/// metadata take them, while it is on.
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white.
/// 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 {
@@ -118,6 +160,7 @@ impl EffectiveOutput {
system_hdr: None,
reported: None,
wide_frame_supported: false,
held: false,
encoding: None,
frame: FrameConfiguration {
format: wgpu::TextureFormat::Bgra8Unorm,
@@ -126,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()
@@ -136,9 +194,10 @@ impl EffectiveOutput {
self.encoding.is_some()
}
/// Whether `mode` asks for HDR here: On always, Auto where the destination offers it and
/// the display reports HDR. The output still needs an offer and a frame that can take it.
/// the display reports HDR, neither while HDR output is held back. The output still needs
/// an offer and a frame that can take it.
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
requests_hdr(mode, self.offered.is_some(), self.system_hdr)
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
}
/// What a 0.2 pack's `Frame` describes: the HDR display on the layered frame, whose
/// picture is light; SDR otherwise, where the frame holds SDR's picture, the one HDR
@@ -169,6 +228,10 @@ fn requests_hdr(mode: HdrMode, offered: bool, system: Option<bool>) -> bool {
HdrMode::Auto => offered && system == Some(true),
}
}
/// Whether `settings` ask for HDR on a destination of `caps` whose display reports `reading`.
fn wanted(settings: &Graphics, caps: OutputCapabilities, reading: DisplayReading) -> bool {
!caps.held && requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr)
}
/// The frame a renderer made now draws in, for `settings`, the pack's `request`, what the
/// destination and device can do (`caps`) and what the display reports.
@@ -179,7 +242,7 @@ pub(crate) fn resolve_frame(
reading: DisplayReading,
swapchain: wgpu::TextureFormat,
) -> FrameConfiguration {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
let (format, hud_layer) = super::frame_configuration(
wanted && settings.hdr_smooth_gradients,
(
@@ -210,12 +273,13 @@ pub(crate) fn resolve_output(
frame: FrameConfiguration,
presents: bool,
) -> EffectiveOutput {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
EffectiveOutput {
offered: caps.hdr,
system_hdr: reading.hdr,
reported: reading.luminance,
wide_frame_supported: caps.wide_frame,
held: caps.held,
encoding: caps
.hdr
.filter(|_| wanted && presents && frame.overlay_format == frame.format),
@@ -225,7 +289,9 @@ pub(crate) fn resolve_output(
settings.peak_nits(reading.luminance) as f32,
settings.black_level_nits(),
],
ui_brightness: settings.hdr_ui_brightness as f32 / 100.0,
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -247,11 +313,13 @@ pub(crate) mod tests {
wide_frame: true,
float_frame: true,
hud_layer: true,
held: false,
},
DisplayReading {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -378,6 +446,118 @@ pub(crate) mod tests {
assert_eq!(settings.hdr, HdrMode::On);
}
/// A window on the driver a start failed on stays SDR, on an SDR frame, whatever the
/// mode, and the mode stays as chosen; another driver tries HDR again, and an offscreen
/// renderer (no driver) never holds HDR back.
#[test]
fn a_failed_start_holds_hdr_back_on_its_driver_only() {
let failed = "Vulkan 10de:2684 GPU; driver; 1.0";
for mode in [HdrMode::On, HdrMode::Auto] {
let settings = Graphics {
hdr: mode,
hdr_failed_start: Some(failed.into()),
..Graphics::default()
};
let request = FrameRequest {
linear_pack: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
let (mut caps, reading) = window(Some(Output::Pq), Some(true));
caps.held = held(&settings, driver);
snapshot(&settings, request, caps, reading)
};
let out = on(Some(failed));
assert_eq!(out.encoding, None, "{mode:?}");
assert_eq!(out.frame.format, Bgra8Unorm, "{mode:?}");
assert!(!out.frame.hud_layer && out.held && !out.requests_hdr(mode));
// Still offered: the page lets the player pick a mode to try again.
assert!(out.hdr_offered());
assert_eq!(settings.hdr, mode);
for driver in [Some("Vulkan 10de:2684 GPU; driver; 1.1"), None] {
let out = on(driver);
assert_eq!(out.encoding, Some(Output::Pq), "{mode:?} {driver:?}");
assert_eq!(out.frame.format, LINEAR_FRAME, "{mode:?} {driver:?}");
assert!(!out.held && out.requests_hdr(mode));
}
}
// A marker that named no driver holds on whichever window comes first.
let legacy = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(String::new()),
..Graphics::default()
};
assert!(held(&legacy, Some(failed)));
assert!(!held(&legacy, None));
}
/// The fingerprint tells another GPU, backend, driver version or system version apart, in
/// one line.
#[test]
fn the_driver_fingerprint_is_one_line_that_changes_with_the_driver() {
let os = "windows (x86_64) version Windows 11 (26200.7019)";
let info = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 4090".into(),
vendor: 0x10de,
device: 0x2684,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
driver: "NVIDIA".into(),
driver_info: "581.57\n".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 32,
subgroup_max_size: 32,
transient_saves_memory: None,
limit_bucket: None,
};
let fingerprint = driver_fingerprint(&info, os);
assert_eq!(
fingerprint,
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57; \
windows (x86_64) version Windows 11 (26200.7019)"
);
assert_ne!(
driver_fingerprint(&info, "windows (x86_64) version Windows 11 (26200.7171)"),
fingerprint
);
// Metal names no driver: a macOS update is what changes it.
let metal = wgpu::AdapterInfo {
name: "Apple M3".into(),
vendor: 0,
device: 0,
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Metal,
..info.clone()
};
let sonoma = driver_fingerprint(&metal, "macos (aarch64) version macOS 14.6");
assert_eq!(
sonoma,
"Metal 0000:0000 Apple M3; ; ; macos (aarch64) version macOS 14.6"
);
assert_ne!(
driver_fingerprint(&metal, "macos (aarch64) version macOS 15.1"),
sonoma
);
let other = [
wgpu::AdapterInfo {
driver_info: "582.02".into(),
..info.clone()
},
wgpu::AdapterInfo {
backend: wgpu::Backend::Dx12,
..info.clone()
},
wgpu::AdapterInfo {
device: 0x2704,
..info.clone()
},
];
for other in other {
assert_ne!(driver_fingerprint(&other, os), fingerprint, "{other:?}");
}
}
/// Auto's paper white and peak are this display's report; the player's own values win.
#[test]
fn auto_levels_follow_this_renderers_report() {
@@ -407,6 +587,39 @@ pub(crate) mod tests {
);
}
/// UI Brightness reaches HDR output as its nits over paper white's: 1 on Auto, whatever
/// paper white is; a value set by hand holds its nits, Paper White on Auto or set by hand.
#[test]
fn ui_brightness_is_its_nits_over_paper_whites() {
let (caps, reading) = window(Some(Output::Pq), Some(true));
let share = |paper_white, ui_nits, reading| {
let settings = Graphics {
hdr: HdrMode::On,
hdr_paper_white: paper_white,
hdr_ui_nits: ui_nits,
..Graphics::default()
};
let frame = resolve_frame(
&settings,
FrameRequest::default(),
caps,
reading,
Bgra8Unorm,
);
resolve_output(&settings, caps, reading, frame, true).ui_brightness
};
// Auto: the report's 400 nits, 203 without one, or the player's 250.
let unreported = DisplayReading::default();
for (paper_white, reading) in [(0, reading), (0, unreported), (250, reading)] {
assert_eq!(share(paper_white, 0, reading), 1.0);
}
assert_eq!(share(0, 100, reading), 0.25);
assert_eq!(share(0, 480, reading), 1.2);
assert_eq!(share(0, 406, unreported), 2.0);
assert_eq!(share(250, 100, reading), 0.4);
assert_eq!(share(250, 480, reading), 1.92);
}
/// Two renderers' states, a window's and an offscreen one's, made and changed in turn:
/// neither's offer or report shows in the other's snapshot, and the offscreen one
/// stopping its watch (HDR Off) leaves the window's report alone.
@@ -457,6 +670,7 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -0,0 +1,945 @@
//! Applying the output a renderer resolves (`output::resolve_output`). Each observation of the
//! settings, the destination's capabilities and the display resolves one candidate. One that
//! changes only values (the luminance, the UI brightness, what the display reports) updates
//! them in place. Another encoding or frame is built and the swapchain configured for it before
//! the snapshot says so. One that fails is never published as active, and is not tried again
//! until the request, the capabilities or the surface change. Auto keeps HDR on through a
//! moment's loss of the display's report ([`auto_grace`]).
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use crate::graphics::HdrMode;
use output::{EffectiveOutput, OutputCapabilities};
use std::time::{Duration, Instant};
/// What an output's resources are made for: the encoding presented and the frame.
type Key = (Option<hdr::Output>, output::FrameConfiguration);
fn key(output: &EffectiveOutput) -> Key {
(output.encoding, output.frame)
}
/// An output whose resources or swapchain configuration failed, with the capabilities it
/// failed on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rejected {
key: Key,
caps: OutputCapabilities,
}
/// What applying an output changes in a renderer.
trait Present {
/// Builds what `output` presents and configures the swapchain for it, then swaps the new
/// resources in. On an error the resources stay as they were, and the swapchain may be
/// configured for neither output.
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String>;
/// Presents SDR whatever happens: a swapchain that doesn't configure now is configured
/// again before the next frame (`surface_unconfigured`).
fn present_sdr(&mut self);
}
/// The snapshot to publish for `candidate` after `applied`, on `caps`. The same resources
/// take the candidate's values as they are. Other ones are presented first and published only
/// once that worked. On a failure the output applied stays where it presents again, else
/// SDR, and the failed one is kept in `rejected` until the candidate's resources or the
/// capabilities change.
fn commit(
target: &mut impl Present,
applied: &EffectiveOutput,
candidate: EffectiveOutput,
caps: OutputCapabilities,
rejected: &mut Option<Rejected>,
) -> EffectiveOutput {
let wanted = key(&candidate);
if rejected.is_some_and(|r| r.key != wanted || r.caps != caps) {
*rejected = None;
}
if wanted == key(applied) {
return candidate;
}
let kept = EffectiveOutput {
encoding: applied.encoding,
..candidate
};
if rejected.is_some() {
return kept;
}
let Err(e) = target.present(&candidate) else {
return candidate;
};
log::warn!(
"Horizon renderer: {} could not be presented ({e}); keeping {}",
name(candidate.encoding),
name(applied.encoding)
);
*rejected = Some(Rejected { key: wanted, caps });
if target.present(&kept).is_ok() {
return kept;
}
log::warn!(
"Horizon renderer: {} no longer presents; SDR",
name(kept.encoding)
);
target.present_sdr();
EffectiveOutput {
encoding: None,
..candidate
}
}
fn name(encoding: Option<hdr::Output>) -> &'static str {
encoding.map_or("SDR", hdr::Output::name)
}
/// The swapchain's configuration for `encoding`: `config` (what every pipeline draws in), or
/// HDR10 or scRGB.
pub(super) fn surface_config(
config: &wgpu::SurfaceConfiguration,
encoding: Option<hdr::Output>,
) -> wgpu::SurfaceConfiguration {
let mut config = config.clone();
if let Some(output) = encoding {
config.format = output.format();
config.view_formats = vec![output.format()];
config.color_space = output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
/// measured sleep/wake time.
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
/// and the surface still offers that encoding, a report that turns false or unknown starts
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
/// offer, and entering HDR take `raw` at once.
fn auto_grace(
now: Instant,
mode: HdrMode,
offered: Option<hdr::Output>,
raw: DisplayReading,
applied: &EffectiveOutput,
since: &mut Option<Instant>,
) -> DisplayReading {
let lost = mode == HdrMode::Auto
&& applied.encoding.is_some()
&& offered == applied.encoding
&& raw.hdr != Some(true);
if !lost {
if since.take().is_some() && raw.hdr == Some(true) {
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
}
} else if since.is_none() {
log::info!(
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
AUTO_OFF_DELAY.as_millis()
);
*since = Some(now);
} else if grace_over(*since, now) {
*since = None;
}
graced(*since, raw, applied)
}
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
}
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
/// luminance `applied` was resolved with; `raw` otherwise.
pub(super) fn graced(
since: Option<Instant>,
raw: DisplayReading,
applied: &EffectiveOutput,
) -> DisplayReading {
match since {
Some(_) => DisplayReading {
luminance: applied.reported,
hdr: Some(true),
},
None => raw,
}
}
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
/// over. Its end reads the display once more first, however long until the next poll, unless
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
fn poll_display(
watch: Option<&mut DisplayWatch>,
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
since: Option<Instant>,
now: Instant,
read: impl FnOnce() -> DisplayReading,
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
) -> bool {
let over = grace_over(since, now);
let Some(watch) = watch else {
return over;
};
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
{
*next = now;
}
let Some(read) = watch.poll(*reading, now, read) else {
return over;
};
observe(reading, caps, read, offer()) || over
}
/// What `surface` offers now (`None` offscreen): Vulkan's offered formats can change with
/// desktop HDR or the window's output, and a display can offer the other encoding.
fn offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
Some(hdr::output(&surface?.get_capabilities(adapter)))
}
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
/// alone included.
fn observe(
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
now: DisplayReading,
offer: Option<Option<hdr::Output>>,
) -> bool {
let before = (*reading, caps.hdr);
*reading = now;
if let Some(offer) = offer {
caps.hdr = offer;
}
before != (*reading, caps.hdr)
}
/// A renderer applying an output, with the marker its HDR output's start writes.
struct Applying<'a> {
renderer: &'a mut Renderer,
marker: &'a std::path::Path,
}
impl Present for Applying<'_> {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
let r = &mut *self.renderer;
let current = r.hdr.as_ref().map(|hdr| hdr.output);
let switching = output.encoding.is_some_and(|e| current != Some(e));
// If the game dies before HDR output shows a frame, its pipelines' build included, the
// marker is still there at the next start, which then holds HDR output back on this
// driver.
if output.encoding.is_none() || switching {
r.mark_hdr_start(output.encoding.is_some(), self.marker);
}
let built = match output.encoding {
Some(encoding) if switching => Some(r.build_hdr(encoding, output)?),
_ => None,
};
if let Some(surface) = &r.surface {
r.frame_gen.stop();
// The submit thread may still present to the old swapchain.
r.queue.wait();
let hdr = built.as_ref().or(r.hdr.as_ref());
Renderer::configure(
&r.device,
surface,
&surface_config(&r.config, output.encoding),
hdr.filter(|_| output.encoding.is_some()),
&r.device_lost,
)?;
r.surface_unconfigured = false;
}
if output.encoding.is_none() {
r.hdr = None;
} else if built.is_some() {
r.hdr = built;
}
Ok(())
}
fn present_sdr(&mut self) {
let r = &mut *self.renderer;
r.hdr = None;
r.mark_hdr_start(false, self.marker);
if let Some(surface) = &r.surface {
r.frame_gen.stop();
r.queue.wait();
r.surface_unconfigured =
Renderer::configure(&r.device, surface, &r.config, None, &r.device_lost).is_err();
}
}
}
impl Renderer {
/// HDR output presenting `encoding` for `output`, made apart from the one presented: from
/// it where there is one, keeping the player's tuning (`Hdr::retargeted`). An error is
/// the device's, caught here rather than left for the next frame.
fn build_hdr(
&self,
encoding: hdr::Output,
output: &EffectiveOutput,
) -> std::result::Result<hdr::Hdr, String> {
let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let mut hdr = match &self.hdr {
Some(hdr) => hdr.retargeted(&self.device, &self.queue, encoding),
None => {
let mut hdr = hdr::Hdr::new(
&self.device,
&self.queue,
output.frame.format,
encoding,
output.frame.hud_layer,
);
hdr.look = self.hdr_lighting_setting;
hdr
}
};
let memory = pollster::block_on(memory.pop());
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
return Err(e.to_string());
}
// The metadata the swapchain's configuration sends.
[hdr.paper_white, hdr.peak, hdr.black] = output.nits;
hdr.ui = output.ui_brightness;
Ok(hdr)
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
let now = Instant::now();
self.watch_hdr(settings.hdr);
// Auto's grace ends on a fresh read, also where a settings change comes before the
// frame's `display_changed`.
if grace_over(self.auto_off_since, now) {
self.read_display(now);
}
self.hold_hdr_back(settings);
let reading = auto_grace(
now,
settings.hdr,
self.output_caps.hdr,
self.reading,
&self.output,
&mut self.auto_off_since,
);
let candidate = output::resolve_output(
settings,
self.output_caps,
reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let (applied, caps) = (self.output, self.output_caps);
let mut rejected = self.output_rejected.take();
let renderer = &mut Applying {
renderer: self,
marker,
};
let next = commit(renderer, &applied, candidate, caps, &mut rejected);
self.output_rejected = rejected;
let [paper_white, peak, black] = next.nits;
if next.encoding != applied.encoding {
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
if (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black) {
(hdr.paper_white, hdr.peak, hdr.black) = (paper_white, peak, black);
self.send_hdr_metadata();
}
}
self.output = next;
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// After a new window surface: what it offers, and an output that failed on the old one
/// may be tried again. The settings apply again.
pub(super) fn surface_recreated(&mut self) {
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.display_fresh = true;
self.output_rejected = None;
}
/// Whether what the system reports for the display, or what the surface offers, changed
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
/// encoding): the settings' Auto values and the output follow it then, so apply them
/// again. Each due poll reads the surface's capabilities as well, even when the display
/// reports the same. So does the end of Auto's grace (`auto_grace`), with or without a
/// poll or an event then.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
self.read_display(Instant::now())
}
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
fn read_display(&mut self, now: Instant) -> bool {
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
poll_display(
self.display.as_mut(),
&mut self.reading,
&mut self.output_caps,
self.auto_off_since,
now,
|| DisplayReading::read(surface, adapter),
|| offer(surface, adapter),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::{Graphics, HdrMode, Reported};
use hdr::Output::{Pq, ScRgb};
use output::FrameRequest;
use output::tests::{snapshot, window};
/// A swapchain that records what it was asked to present, and fails `fails`.
#[derive(Default)]
struct Swapchain {
fails: Vec<Option<hdr::Output>>,
/// Each encoding presented, with the luminance its metadata sends.
asked: Vec<(Option<hdr::Output>, [f32; 3])>,
sdr: usize,
}
impl Present for Swapchain {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
self.asked.push((output.encoding, output.nits));
if self.fails.contains(&output.encoding) {
return Err("unsupported".into());
}
Ok(())
}
fn present_sdr(&mut self) {
self.sdr += 1;
}
}
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
hdr_float: true,
};
fn on() -> Graphics {
Graphics {
hdr: HdrMode::On,
..Graphics::default()
}
}
/// The candidate on a renderer whose frame is `applied`'s.
fn candidate(
settings: &Graphics,
caps: OutputCapabilities,
reading: DisplayReading,
applied: &EffectiveOutput,
) -> EffectiveOutput {
output::resolve_output(settings, caps, reading, applied.frame, true)
}
/// HDR stays on while the display offers scRGB instead of HDR10 and then HDR10 again:
/// the swapchain, its metadata and the snapshot follow each, with no Off between.
#[test]
fn the_output_follows_pq_to_scrgb_and_back_while_hdr_stays_on() {
let settings = on();
let (mut caps, reading) = window(Some(Pq), Some(true));
let mut applied = snapshot(&settings, NO_PACK, caps, reading);
assert_eq!(applied.encoding, Some(Pq));
let (mut swapchain, mut rejected) = (Swapchain::default(), None);
for encoding in [ScRgb, Pq] {
caps.hdr = Some(encoding);
let next = candidate(&settings, caps, reading, &applied);
applied = commit(&mut swapchain, &applied, next, caps, &mut rejected);
assert_eq!(
(applied.encoding, applied.offered),
(Some(encoding), Some(encoding))
);
assert_eq!(
applied.frame,
snapshot(&settings, NO_PACK, caps, reading).frame
);
}
let metadata = [400.0, 1000.0, 0.0];
assert_eq!(
swapchain.asked,
[(Some(ScRgb), metadata), (Some(Pq), metadata)]
);
assert_eq!((rejected, swapchain.sdr), (None, 0));
}
/// The display reports the same luminance and HDR, but the surface now offers scRGB: the
/// next due poll reads it, and the output follows with the same nits and report.
#[test]
fn a_change_of_offer_alone_is_seen_at_the_next_poll() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
let start = std::time::Instant::now();
let mut watch = DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: Some(start + super::super::DISPLAY_POLL),
};
let same = reading;
assert_eq!(watch.poll(reading, start, || unreachable!()), None);
let due = start + super::super::DISPLAY_POLL;
assert_eq!(watch.poll(reading, due, || same), Some(same));
assert_eq!(watch.polled, Some(due + super::super::DISPLAY_POLL));
assert!(observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
// Nothing changed at the next poll, and offscreen nothing is offered to read.
assert!(!observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
assert!(!observe(&mut reading, &mut caps, same, None));
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut Swapchain::default(), &applied, next, caps, &mut None);
assert_eq!(published.encoding, Some(ScRgb));
assert_eq!(
(published.nits, published.reported, published.system_hdr),
(applied.nits, applied.reported, applied.system_hdr)
);
}
/// The display's luminance or the UI brightness change on the same output: the values
/// follow, and nothing is built or configured again.
#[test]
fn new_nits_on_the_same_output_build_nothing() {
let mut settings = on();
let (caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
let mut swapchain = Swapchain::default();
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
assert_eq!(published, next);
assert_eq!(published.nits, [250.0, 600.0, 0.0]);
assert_eq!(published.ui_brightness, 0.6);
assert!(swapchain.asked.is_empty());
}
/// An encoding that fails is never the snapshot's: the output applied stays where it
/// presents again, else SDR. It is not tried again each frame, nor for new nits, only
/// once the request or the capabilities change.
#[test]
fn an_output_that_fails_is_never_active_nor_tried_every_frame() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let pq = snapshot(&settings, NO_PACK, caps, reading);
caps.hdr = Some(ScRgb);
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(published.encoding, Some(Pq));
assert_eq!(swapchain.asked.len(), 2, "scRGB, then HDR10 again");
// Frames after, and a new luminance: no new attempt.
for paper_white in [400, 400, 300] {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
assert_eq!(again.encoding, Some(Pq));
assert_eq!(again.nits[0], paper_white as f32);
}
assert_eq!(swapchain.asked.len(), 2);
// Another request (HDR Off, then On) tries it again.
let off = Graphics::default();
let sdr = commit(
&mut swapchain,
&published,
candidate(&off, caps, reading, &published),
caps,
&mut rejected,
);
assert_eq!((sdr.encoding, rejected), (None, None));
let next = candidate(&settings, caps, reading, &sdr);
assert_eq!(
commit(&mut swapchain, &sdr, next, caps, &mut rejected).encoding,
None
);
assert_eq!(
swapchain.asked.iter().map(|a| a.0).collect::<Vec<_>>(),
[Some(ScRgb), Some(Pq), None, Some(ScRgb), None]
);
// So does another capability (the hold lifted here stands for any).
caps.held = true;
let held = candidate(&settings, caps, reading, &sdr);
assert_eq!(held.encoding, None);
commit(&mut swapchain, &sdr, held, caps, &mut rejected);
caps.held = false;
let asked = swapchain.asked.len();
let next = candidate(&settings, caps, reading, &sdr);
commit(&mut swapchain, &sdr, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), asked + 2);
// The output applied failing too: SDR, never the failed one.
let mut broken = Swapchain {
fails: vec![Some(ScRgb), Some(Pq)],
..Default::default()
};
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut broken, &pq, next, caps, &mut None);
assert_eq!((published.encoding, broken.sdr), (None, 1));
assert_eq!(settings.hdr, HdrMode::On, "the mode stays");
// A new surface forgets the failure (`surface_recreated`): the same request on the
// same capabilities is tried again.
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
for _ in 0..2 {
commit(&mut swapchain, &pq, next, caps, &mut rejected);
}
assert_eq!(swapchain.asked.len(), 2);
rejected = None;
commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), 4);
}
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
/// fake time as the game follows a renderer each frame: `display_changed`, then
/// `update_output` where it says so.
struct Auto {
settings: Graphics,
start: Instant,
watch: DisplayWatch,
reading: DisplayReading,
caps: OutputCapabilities,
applied: EffectiveOutput,
since: Option<Instant>,
swapchain: Swapchain,
/// What the system reports when it is read, and when (ms) it was.
report: DisplayReading,
reads: Vec<u64>,
}
impl Auto {
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
/// (`event`), as on Wayland.
fn new(polled: bool) -> Self {
let settings = Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
};
let (caps, reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
let start = Instant::now();
Self {
settings,
start,
watch: DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: polled.then_some(start),
},
reading,
caps,
applied,
since: None,
swapchain: Swapchain::default(),
report: reading,
reads: Vec::new(),
}
}
/// A frame at `ms`.
fn frame(&mut self, ms: u64) {
if self.read_display(ms) {
self.update(ms);
}
}
/// `Renderer::read_display` at `ms`.
fn read_display(&mut self, ms: u64) -> bool {
let now = self.start + Duration::from_millis(ms);
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
let read = || {
reads.push(ms);
report
};
let (reading, caps) = (&mut self.reading, &mut self.caps);
poll_display(
Some(&mut self.watch),
reading,
caps,
self.since,
now,
read,
|| offer,
)
}
/// Frames from `from` to `to` ms, every 10 ms.
fn frames(&mut self, from: u64, to: u64) {
for ms in (from..=to).step_by(10) {
self.frame(ms);
}
}
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
/// HDR, at 300/800 nits then.
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.report = DisplayReading {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
self.caps.hdr = offer;
}
/// The system's event at `ms`, with what it now `reports`.
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.reports(hdr, offer);
self.reading = self.report;
self.update(ms);
}
/// `update_output` at `ms`, in its order.
fn update(&mut self, ms: u64) {
let now = self.start + Duration::from_millis(ms);
if grace_over(self.since, now) {
self.read_display(ms);
}
let applied = &self.applied;
let reading = auto_grace(
now,
self.settings.hdr,
self.caps.hdr,
self.reading,
applied,
&mut self.since,
);
let next = candidate(&self.settings, self.caps, reading, applied);
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
// this one: no new renderer.
let reading = graced(self.since, self.reading, &self.applied);
let frame = output::resolve_frame(
&self.settings,
NO_PACK,
self.caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(
frame == self.applied.frame,
self.applied.hdr_on(),
"{ms} ms"
);
}
fn presented(&self) -> Vec<Option<hdr::Output>> {
self.swapchain.asked.iter().map(|a| a.0).collect()
}
}
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
/// is presented again, and the shader `Frame`, what the settings show as active and the
/// nits stay as they were applied, even while the report says nothing of them.
#[test]
fn a_report_that_flickers_off_and_back_swaps_nothing() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
let before = auto.applied;
auto.event(0, Some(true), Some(Pq));
auto.frames(0, 90);
let fresh = auto.applied;
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
auto.event(100, lost, Some(Pq));
auto.frames(100, 390);
assert_eq!(auto.applied, fresh, "{lost:?}");
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
auto.event(400, Some(true), Some(Pq));
auto.frames(400, 1500);
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
assert!(auto.presented().is_empty(), "{lost:?}");
assert_eq!(auto.applied.frame, before.frame);
}
}
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
/// display with no event then. The same loss reported again doesn't restart the timer.
#[test]
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
auto.event(0, lost, Some(Pq));
auto.frames(0, 190);
auto.event(200, lost, Some(Pq));
auto.frames(200, 490);
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
auto.frame(500);
assert_eq!(auto.applied.encoding, None, "{lost:?}");
auto.frames(500, 3000);
assert_eq!(auto.presented(), [None], "{lost:?}");
assert_eq!(auto.since, None);
}
}
/// Where the system is polled, a false read by the poll that starts the timer is read
/// again at the deadline, not two seconds after, before it counts: the display came back
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
/// stays otherwise.
#[test]
fn a_polled_false_is_read_again_at_the_deadline() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
assert_eq!(
(auto.applied.encoding, auto.since.is_some()),
(Some(Pq), true)
);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
/// display before the grace ends, and the frame after reads it no more: the display came
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
#[test]
fn a_settings_change_at_the_deadline_reads_the_display_first() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
auto.update(2500);
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A positive report clears the timer: a later loss has its own 500 ms.
#[test]
fn a_positive_report_clears_the_timer() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(300, Some(true), Some(Pq));
auto.event(700, Some(false), Some(Pq));
auto.frames(700, 1190);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(1200);
assert_eq!(auto.presented(), [None]);
}
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
#[test]
fn off_and_on_take_no_grace() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::Off;
auto.update(200);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::On;
auto.update(200);
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
auto.settings.hdr = HdrMode::Auto;
auto.update(300);
auto.frames(300, 790);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(800);
assert_eq!(auto.presented(), [None]);
}
/// The surface no longer offering the encoding presented ends the grace at once: SDR
/// without a report, the other encoding with one.
#[test]
fn an_encoding_no_longer_offered_is_not_kept() {
for offer in [None, Some(ScRgb)] {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(false), offer);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, None, offer);
assert_eq!(auto.presented(), [None], "{offer:?}");
}
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(true), Some(ScRgb));
assert_eq!(auto.presented(), [Some(ScRgb)]);
}
/// Two windows' timers run apart.
#[test]
fn two_renderers_keep_their_own_timer() {
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
b.start = a.start;
a.event(100, Some(false), Some(Pq));
b.event(300, Some(false), Some(Pq));
a.event(400, Some(true), Some(Pq));
for ms in (300..=900).step_by(10) {
a.frame(ms);
b.frame(ms);
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
}
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
}
}
@@ -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(),
@@ -4,8 +4,7 @@
use std::collections::BTreeMap;
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,8 +18,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -55,6 +54,10 @@ mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
mod image_formats;
use image_formats::{image_format, storage_formats_of};
#[cfg(test)]
mod full_outputs_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -889,7 +892,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -908,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -922,7 +925,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1579,7 +1582,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_render_pipeline::buffer_index(&t.name)
|| horizon_shaders::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -1826,98 +1829,6 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
@@ -2521,7 +2432,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -2805,10 +2716,21 @@ impl PackPost {
.shadow
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
let mut plain = post.shadow_water_plain;
let shadow_water = post
.shadow_water
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
let shadow_water = shadow_water.or_else(|| {
unpatched
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
});
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
let geometry = PackSky::new(device);
@@ -3365,6 +3287,17 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
// Said as what it is, not as a program skipped.
if let Some(name) = water_unpatched {
let skipped = format!("{name}: skipped: ");
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
*n = format!(
"{name}: translucent terrain without the boats' water patches, as the GPU \
refused it with them ({})",
&n[skipped.len()..]
);
}
}
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
@@ -0,0 +1,109 @@
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
//! the program without them, not no shadow program. The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack(outputs: u32) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let declared: String = (0..outputs)
.map(|i| format!("varying vec4 v{i};\n"))
.collect();
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
add(
"shadow.vsh",
&format!(
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
),
);
add(
"shadow.fsh",
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-full-outputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let geometry = || {
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
})
};
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
let post = gpu.build_with_terrain(pack(outputs), geometry());
assert!(post.has_shadow(), "{:?}", post.notes);
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
post.notes
.iter()
.any(|n| n.contains("without the boats' water patches")),
"{:?}",
post.notes
);
// With room for them, the patches stay and nothing is said.
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
!post.notes.iter().any(|n| n.contains("water patches")),
"{:?}",
post.notes
);
}
@@ -0,0 +1,97 @@
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
//! storage images declare.
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
pub(super) fn storage_formats_of(
module: &mut naga::Module,
entries: &[wgpu::BindGroupLayoutEntry],
) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_render_pipeline::SHADOW_COLORS;
use horizon_shaders::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -129,7 +125,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,8 +1,7 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -119,12 +118,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_render_pipeline::buffer_index(name);
let buffer = horizon_shaders::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_render_pipeline::Shadow;
use horizon_shaders::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -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,79 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -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;
@@ -5,6 +5,9 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -50,6 +50,98 @@ struct TaaResources {
uniform: wgpu::Buffer,
}
/// One frame handed to an upscaler backend: the jittered world at `render_size` and what
/// it needs to rebuild it at `output_size`. The motion is the camera's alone (`reproject`);
/// objects that move on their own carry no motion vectors yet.
// Only TAA reads it so far; a vendor backend reads the rest (#12918).
#[allow(dead_code)]
pub(super) struct UpscaleFrame<'a> {
/// The world's colour, rendered with this frame's jitter.
pub color: &'a wgpu::TextureView,
/// The world's depth, at `render_size`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
pub output_size: [u32; 2],
/// The jitter in clip space, as added to the projection.
pub jitter_clip: [f32; 2],
/// The same jitter in rendered pixels, y down.
pub jitter_pixels: [f32; 2],
/// This frame's unjittered clip space to the previous frame's.
pub reproject: [[f32; 4]; 4],
/// The history is unusable: a cut, a resize, a frame without the upscaler.
pub reset: bool,
/// Frames jittered so far.
pub index: u32,
}
/// Something that turns the jittered low-resolution world into the output picture.
/// TAA (`TaaResources`) is the only backend; `Target` is the per-size state a backend
/// keeps beside the world target (TAA's history textures).
pub(super) trait UpscalerBackend {
type Target;
/// The name its GPU timing is shown under.
fn name(&self) -> &'static str;
/// Write this frame's output and return the bind group that shows it (`draw_onto`).
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
target: &'t Self::Target,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup;
}
impl UpscalerBackend for TaaResources {
type Target = History;
fn name(&self) -> &'static str {
"TAA"
}
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
taa: &'t History,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup {
// The resolve bind group already samples `frame.color` and `frame.depth`.
let i = (frame.index % 2) as usize;
let uniform = TaaUniform {
reproject: frame.reproject,
jitter: [
frame.jitter_clip[0],
frame.jitter_clip[1],
if frame.reset { 1.0 } else { 0.0 },
0.0,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.and_then(|t| t.writes(self.name())),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
&taa.show[i]
}
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
@@ -563,33 +655,25 @@ impl Post {
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
let size = self.size().unwrap_or([1, 1]);
let frame = UpscaleFrame {
color: &target.color,
depth: &target.depth,
render_size: size,
output_size: taa.size,
jitter_clip: [self.taa_this_frame.jitter[0], self.taa_this_frame.jitter[1]],
jitter_pixels: self.jitter_pixels(),
reproject: self.taa_this_frame.reproject,
reset: self.taa_this_frame.jitter[2] > 0.5,
index: self.taa_frame,
};
let shown = res.resolve(queue, encoder, &frame, taa, timing.as_deref_mut());
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
self.draw_onto(encoder, screen, shown, pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
+307 -37
View File
@@ -5,9 +5,23 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
/// Written beside it before a window's HDR output is switched on, naming the graphics driver it
/// starts on ([`hdr_marker_text`]), and removed once the first HDR frame is presented. Found at
/// the next start, it means HDR output took the game down there: HDR output is held back on
/// that driver ([`Graphics::hdr_failed_start`]), and the mode stays as the player chose it.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// The marker's text for a window whose HDR output starts on `driver`
/// (`gfx::output::window_driver`).
pub fn hdr_marker_text(driver: &str) -> String {
format!("driver:{driver}\n")
}
/// The driver a marker names; empty for one written before markers named it.
fn marker_driver(text: &str) -> &str {
text.lines()
.find_map(|line| line.strip_prefix("driver:"))
.unwrap_or_default()
.trim()
}
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak. Each renderer keeps its
@@ -16,6 +30,23 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// 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,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but not where its peak comes from: the page offers calibration.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -38,6 +69,23 @@ pub fn peak_step_of(nits: f32) -> f32 {
};
step.clamp(0.0, PEAK_STEPS as f32)
}
/// The steps of Paper White's and UI Brightness's scale: Auto (0), then 10 to 480 nits by 10.
pub const NITS_STEPS: u32 = 48;
/// Paper White's or UI Brightness's setting at slider position `v`, 0 to 1 ([`NITS_STEPS`]).
pub fn nits_at(v: f32) -> u32 {
(v * NITS_STEPS as f32).round() as u32 * 10
}
/// Where a Paper White or UI Brightness setting sits on its slider, 0 to 1.
pub fn nits_position(nits: u32) -> f32 {
nits as f32 / (NITS_STEPS * 10) as f32
}
/// A saved Paper White or UI Brightness: Auto (0), or 10 to 480 nits.
fn nits_setting(nits: u32) -> u32 {
match nits {
0 => 0,
n => n.clamp(10, 480),
}
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -490,8 +538,6 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -801,14 +847,17 @@ pub struct Graphics {
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// HDR brightness of the HUD and menus, in nits; 0 is Auto: at paper white
/// ([`Graphics::ui_nits`]). The first-person hand stays at the world's.
pub hdr_ui_nits: u32,
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
pub hdr_lighting: bool,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// The graphics driver whose last start ended before HDR output showed a frame (the
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
/// Empty for a marker that named no driver: the first one seen holds it.
pub hdr_failed_start: Option<String>,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -881,9 +930,9 @@ impl Default for Graphics {
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_ui_nits: 0,
hdr_lighting: true,
hdr_key: "key.keyboard.unknown".into(),
hdr_failed_start: None,
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
@@ -909,17 +958,54 @@ impl Graphics {
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
let marker = dir.join(HDR_MARKER);
if let Ok(text) = fs::read_to_string(&marker)
&& fs::remove_file(&marker).is_ok()
&& g.hdr != HdrMode::Off
{
g.hdr_failed_start = Some(marker_driver(&text).to_owned());
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
"the last start ended before HDR output showed a frame; HDR output stays off with that graphics driver until the HDR mode is changed (graphics option hdrFailedStart, in {FILE})"
);
}
g
}
/// Whether HDR output is held back on a window whose graphics driver is `driver`
/// (`gfx::output::window_driver`): the last start on it ended before HDR output
/// showed a frame ([`Graphics::hdr_failed_start`]).
pub fn hdr_held(&self, driver: &str) -> bool {
self.hdr_failed_start
.as_deref()
.is_some_and(|failed| failed.is_empty() || failed == driver)
}
/// The window's renderer runs on `driver`: a failed start that named no driver is this
/// one's now, and another driver's (a driver update, another GPU) is dropped, so HDR output
/// is tried again. True when it changed.
pub fn settle_failed_start(&mut self, driver: &str) -> bool {
match self.hdr_failed_start.as_deref() {
Some("") => self.hdr_failed_start = Some(driver.to_owned()),
Some(failed) if failed != driver => self.hdr_failed_start = None,
_ => return false,
}
true
}
/// `before`, as Undo brings it back, but never the hold on a driver HDR output failed to
/// start on: that stays as it is now, and goes when Undo changes the mode, as any change of
/// the mode drops it.
pub fn undone_to(&self, before: &Self) -> Self {
Self {
hdr_failed_start: self
.hdr_failed_start
.clone()
.filter(|_| self.hdr == before.hdr),
..before.clone()
}
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
// UI Brightness: `hdrUiNits`, else an older file's share of Paper White.
let (mut ui_nits, mut ui_share) = (None, None);
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -1009,12 +1095,9 @@ impl Graphics {
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(10, 480),
}
}
"hdrPaperWhite" => g.hdr_paper_white = nits_setting(number(g.hdr_paper_white)),
"hdrUiNits" => ui_nits = value.parse().ok().map(nits_setting),
"hdrUiBrightness" => ui_share = value.parse::<u32>().ok(),
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
@@ -1022,10 +1105,7 @@ impl Graphics {
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
@@ -1042,11 +1122,20 @@ impl Graphics {
_ => {}
}
}
// The share was 25 to 100% of Paper White. 100% is Auto, and so is any share of an
// Auto Paper White, whose nits the file doesn't hold; another share of a Paper White set
// by hand is its nits, to the nearest step.
g.hdr_ui_nits = ui_nits.unwrap_or(match (ui_share, g.hdr_paper_white) {
(Some(share), paper) if share < 100 && paper != 0 => {
((paper * share.max(25) + 500) / 1000).max(1) * 10
}
_ => 0,
});
g
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1093,9 +1182,8 @@ impl Graphics {
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
self.hdr_ui_nits,
bool_text(self.hdr_lighting),
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
@@ -1104,17 +1192,12 @@ impl Graphics {
bool_text(self.hdr_smooth_gradients),
self.gpu_culling.key(),
);
if let Some(driver) = &self.hdr_failed_start {
text += &format!("hdrFailedStart:{driver}\n");
}
text += &self.blue_map.serialize();
text
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
HDR_KEY => self.hdr_key = value,
_ => return false,
}
true
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
@@ -1126,6 +1209,13 @@ impl Graphics {
n => n,
}
}
/// The HUD and menus' brightness, in nits: the player's, or on Auto paper white's.
pub fn ui_nits(&self, reported: Option<Reported>) -> u32 {
match self.hdr_ui_nits {
0 => self.paper_white_nits(reported),
n => n,
}
}
/// The peak the world's highlights reach, in nits: the player's, or on Auto
/// what the system `reported`, else 1000.
pub fn peak_nits(&self, reported: Option<Reported>) -> u32 {
@@ -1134,6 +1224,15 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1206,6 +1305,83 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
mod tests {
use super::*;
/// UI Brightness is saved in nits, 0 for Auto. An older file's share of Paper White reads
/// as Auto at 100% or with Paper White on Auto, else as that share of it to the nearest step.
#[test]
fn ui_brightness_round_trips_in_nits_and_reads_older_shares() {
for (file, nits) in [
("", 0),
("hdrUiNits:0\n", 0),
("hdrUiNits:160\n", 160),
("hdrUiNits:5\n", 10),
("hdrUiNits:9000\n", 480),
("hdrUiNits:x\n", 0),
// Older files: 100% is Auto,
("hdrPaperWhite:200\nhdrUiBrightness:100\n", 0),
// any share of Paper White's Auto too,
("hdrUiBrightness:60\n", 0),
("hdrPaperWhite:0\nhdrUiBrightness:25\n", 0),
// and another share of a Paper White set by hand is its nits.
("hdrPaperWhite:200\nhdrUiBrightness:60\n", 120),
("hdrUiBrightness:50\nhdrPaperWhite:203\n", 100),
("hdrPaperWhite:250\nhdrUiBrightness:75\n", 190),
("hdrPaperWhite:10\nhdrUiBrightness:25\n", 10),
("hdrPaperWhite:480\nhdrUiBrightness:10\n", 120),
// The new key wins over the old one.
(
"hdrPaperWhite:200\nhdrUiBrightness:60\nhdrUiNits:300\n",
300,
),
("hdrUiNits:0\nhdrPaperWhite:200\nhdrUiBrightness:60\n", 0),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_ui_nits, nits, "{file}");
let saved = g.serialize();
assert!(!saved.contains("hdrUiBrightness"), "{file}");
assert!(saved.contains(&format!("\nhdrUiNits:{nits}\n")), "{file}");
assert_eq!(Graphics::parse(&saved), g, "{file}");
}
let mut g = Graphics::default();
assert_eq!(g.ui_nits(None), 203);
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
assert_eq!(g.ui_nits(reported), 120);
g.hdr_ui_nits = 400;
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
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 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)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
@@ -1243,6 +1419,85 @@ mod tests {
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// A start that finds the marker (HDR output took the last one down) keeps the mode the
/// player chose and saves the driver the marker names; HDR Off saves nothing.
#[test]
fn a_marker_keeps_the_mode_and_holds_the_driver_it_names() {
let driver = "Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57";
for mode in HdrMode::ALL {
let dir = scratch(&format!("hdr-marker-{mode:?}"));
let saved = Graphics {
hdr: mode,
..Graphics::default()
};
saved.save(&dir);
fs::write(dir.join(HDR_MARKER), hdr_marker_text(driver)).unwrap();
let g = Graphics::load(&dir);
let failed = (mode != HdrMode::Off).then(|| driver.to_owned());
assert_eq!((g.hdr, &g.hdr_failed_start), (mode, &failed), "{mode:?}");
assert!(!dir.join(HDR_MARKER).exists(), "{mode:?}");
// Saved for good: the next start, without a marker, reads the same.
let again = Graphics::load(&dir);
assert_eq!((again.hdr, again.hdr_failed_start), (mode, failed));
let _ = fs::remove_dir_all(&dir);
}
}
/// Today's marker names no driver: it holds whichever driver the window starts on first,
/// and is that driver's from then on.
#[test]
fn a_marker_without_a_driver_holds_the_first_one_seen() {
let dir = scratch("hdr-marker-legacy");
Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
}
.save(&dir);
fs::write(dir.join(HDR_MARKER), "HDR output was being switched on\n").unwrap();
let mut g = Graphics::load(&dir);
assert_eq!(
(g.hdr, g.hdr_failed_start.as_deref()),
(HdrMode::Auto, Some(""))
);
let (first, second) = (
"Vulkan 10de:2684 GPU; NVIDIA; 1",
"Dx12 1002:744c GPU; AMD; 2",
);
assert!(g.hdr_held(first) && g.hdr_held(second));
assert!(g.settle_failed_start(first));
assert!(g.hdr_held(first) && !g.hdr_held(second));
assert!(!g.settle_failed_start(first));
let _ = fs::remove_dir_all(&dir);
}
/// The window starting on another driver (an update, another GPU) drops the hold, so HDR
/// output is tried again; on the same driver it stays.
#[test]
fn another_driver_drops_the_hold() {
let held = "Vulkan 10de:2684 GPU; NVIDIA; 581.57";
let mut g = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(held.into()),
..Graphics::default()
};
assert!(!g.settle_failed_start(held));
assert!(g.hdr_held(held));
assert!(g.settle_failed_start("Vulkan 10de:2684 GPU; NVIDIA; 582.02"));
assert_eq!((g.hdr, g.hdr_failed_start), (HdrMode::On, None));
assert!(!Graphics::default().hdr_held(held));
}
#[test]
fn round_trips() {
let g = Graphics {
@@ -1292,9 +1547,11 @@ mod tests {
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_ui_nits: 160,
hdr_lighting: false,
hdr_key: "key.keyboard.h".into(),
hdr_failed_start: Some(
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
@@ -1303,6 +1560,15 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
// A legacy marker's hold, which names no driver, round-trips too; none writes no line.
for failed in [Some(String::new()), None] {
let graphics = Graphics {
hdr_failed_start: failed,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert!(!Graphics::default().serialize().contains("hdrFailedStart"));
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
@@ -1424,6 +1690,10 @@ mod tests {
Graphics::parse("shaderPack:Pack.zip\n"),
Graphics::default()
);
// The HDR page's old shortcut key loads as nothing, and the next save drops it.
let old = Graphics::parse("hdr:true\nhdrKey:key.keyboard.h\nhdrPeak:1000\n");
assert_eq!((old.hdr, old.hdr_peak), (HdrMode::On, 1000));
assert!(!old.serialize().contains("hdrKey"));
assert_eq!(
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
@@ -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);
}
+125 -95
View File
@@ -8,13 +8,14 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
},
graphics_style::Cost,
lang,
options_ui::{Event, Page as OptionsPage},
settings::{Opt, Settings},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
text::Text,
};
@@ -106,8 +107,6 @@ pub enum H {
FrameGeneration,
Hdr,
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
/// Starts HDR calibration (`crate::hdr_calibration`).
HdrCalibrate,
HdrPeak,
@@ -614,7 +613,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrUiBrightness,
None,
"How bright the HUD and menus are, against paper white.",
"How bright the HUD and menus are. Auto: at paper white.",
),
h(
H::HdrLighting,
@@ -623,14 +622,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
"Shortcut",
vec![h(
H::HdrKey,
None,
"A key that opens this screen from the game. Not bound at first.",
)],
),
],
),
];
@@ -685,29 +676,14 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
s.horizon.hdr_setup(out.reported)
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
const HDR_HELD: &str = "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.";
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(
item: Item,
@@ -716,16 +692,23 @@ fn description(
text: &'static str,
) -> &'static str {
match (item, setup(s, out)) {
(Item::Horizon(H::Hdr), _) if out.held => HDR_HELD,
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"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."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
// 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) =>
{
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -814,14 +797,14 @@ impl H {
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
H::HdrUiBrightness => Some(crate::graphics::nits_position(g.hdr_ui_nits)),
// Auto, then the peak's scale.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
_ => None,
}
}
@@ -833,13 +816,26 @@ impl H {
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(48),
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
H::HdrPeak => Some(PEAK_POSITIONS),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
}
}
/// "Paper White: Auto (203)" or "Paper White: 160": a setting in nits, 0 for Auto, which
/// shows the nits Auto gives.
fn nits_label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let (g, r) = (&s.horizon, out.reported);
let (caption, set, auto) = match self {
H::HdrPaperWhite => ("Paper White", g.hdr_paper_white, g.paper_white_nits(r)),
H::HdrPeak => ("Peak Brightness", g.hdr_peak, g.peak_nits(r)),
_ => ("UI Brightness", g.hdr_ui_nits, g.ui_nits(r)),
};
match set {
0 => labelled(caption, lang::tr("Auto (%s)", &[&auto])),
n => labelled(caption, n),
}
}
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
let g = &s.horizon;
match self {
@@ -936,30 +932,13 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown => "Calibrate...",
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s)", &[&g.paper_white_nits(out.reported)]),
),
n => labelled("Paper White", n.to_string()),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s)", &[&g.peak_nits(out.reported)]),
),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrPaperWhite | H::HdrPeak | H::HdrUiBrightness => self.nits_label(s, out),
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
// A press across the row, as Calibrate...: the list it opens shows the pick.
@@ -1000,7 +979,8 @@ impl H {
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
H::HdrPeak => {
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
0 => 0,
@@ -1008,7 +988,6 @@ impl H {
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
_ => {}
}
}
@@ -1086,7 +1065,9 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
// Any choice tries HDR output again on a driver a start failed on.
H::Hdr => {
g.hdr_failed_start = None;
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !out.hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
@@ -1129,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.");
@@ -1155,7 +1137,14 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
HDR_HELD
} 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() =>
{
@@ -1178,10 +1167,7 @@ impl Item {
fn steps(self, s: &Settings, out: &EffectiveOutput) -> bool {
self.switch(s, out).is_none()
&& self.slider(s).is_none()
&& !matches!(
self,
Item::Horizon(H::HdrKey | H::HdrCalibrate | H::ShaderPacks)
)
&& !matches!(self, Item::Horizon(H::HdrCalibrate | H::ShaderPacks))
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings, out: &EffectiveOutput) -> Option<bool> {
@@ -1233,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(
@@ -1332,16 +1321,14 @@ 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>) {
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
if item == Item::Horizon(H::HdrKey) {
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
return (lang::get("Shortcut"), Some(key));
}
shell::name_and_value(&item.label(s, out).plain())
}
@@ -1350,13 +1337,9 @@ fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<Str
fn row(setting: Setting, s: &Settings, out: &EffectiveOutput) -> Row<Item> {
let item = setting.item;
let (name, value) = parts(item, s, out);
let value = match (item, item.switch(s, out), item.slider(s)) {
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
crate::graphics::HDR_KEY,
value.unwrap_or_default(),
)),
(_, Some(on), _) => Value::Switch(on),
(_, None, Some(position)) => Value::Slider {
let value = match (item.switch(s, out), item.slider(s)) {
(Some(on), _) => Value::Switch(on),
(None, Some(position)) => Value::Slider {
position,
steps: item.slider_steps(),
text: value.unwrap_or_default(),
@@ -1454,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 {
@@ -1522,7 +1505,7 @@ impl Screen for GraphicsScreen {
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = b.horizon.clone();
s.horizon = s.horizon.undone_to(&b.horizon);
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
s.gamma = b.gamma;
@@ -1572,7 +1555,7 @@ mod tests {
use super::*;
use crate::{
graphics_style as style,
settings_shell::{Capture, Columns, Hosted, Shell, Target},
settings_shell::{Columns, Hosted, Shell, Target},
};
use winit::keyboard::KeyCode as K;
@@ -2014,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]
@@ -2038,10 +2046,6 @@ mod tests {
precheck.why.as_deref(),
Some("Only applies to asynchronous entity culling.")
);
let key = find(&s, Item::Horizon(H::HdrKey));
assert_eq!(key.name, "Shortcut");
let waits = Capture::Key(crate::graphics::HDR_KEY);
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
assert_eq!(calibrate.value, Value::Press);
}
@@ -2138,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]
@@ -2726,7 +2756,7 @@ mod tests {
H::HdrPaperWhite => s.horizon.hdr_paper_white,
H::HdrPeak => s.horizon.hdr_peak,
H::HdrBlackLevel => s.horizon.hdr_black_level,
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
H::HdrUiBrightness => s.horizon.hdr_ui_nits,
_ => unreachable!(),
};
let fresh = || {
@@ -2,7 +2,7 @@
//! reports.
use super::tests::{CHANGED, CLICK, DRAGGED, FB, NONE, SIZE, Ui, offering, press, row, ui};
use super::*;
use crate::settings_shell::{Capture, Columns, Hosted};
use crate::settings_shell::{Columns, Hosted};
use winit::keyboard::KeyCode as K;
#[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();
@@ -64,17 +107,12 @@ fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
ui.release();
// Calibrate starts the calibration; the shortcut row waits for a key.
// Calibrate starts the calibration.
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
assert_eq!(
press(&mut ui, &mut s, at(key)),
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
);
assert!(ui.changed());
}
@@ -125,12 +163,215 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
#[test]
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
/// in use, and a value set by hand its own nits, whatever Paper White is. Its slider has Paper
/// White's steps: Auto, then 10 to 480 nits by 10.
#[test]
fn ui_brightness_reads_in_nits_on_paper_whites_steps() {
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let reporting = offering(true, Some(true));
let label = |s: &Settings, out| Item::Horizon(H::HdrUiBrightness).label(s, out).plain();
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (400)");
assert_eq!(label(&s, &NONE), "UI Brightness: Auto (203)");
s.horizon.hdr_paper_white = 250;
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (250)");
s.horizon.hdr_ui_nits = 160;
for paper_white in [0, 80, 250] {
s.horizon.hdr_paper_white = paper_white;
assert_eq!(label(&s, &reporting), "UI Brightness: 160");
}
let item = Item::Horizon(H::HdrUiBrightness);
assert_eq!(
H::HdrUiBrightness.slider_steps(),
H::HdrPaperWhite.slider_steps()
);
H::HdrUiBrightness.set_slider(&mut s, 0.0);
let mut reached = vec![s.horizon.hdr_ui_nits];
for _ in 0..H::HdrUiBrightness.slider_steps().unwrap() {
item.step_slider(&mut s, true, 200.0, &reporting);
reached.push(s.horizon.hdr_ui_nits);
}
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
assert_eq!(reached, paper);
// Each value sits where Paper White's same value does.
for nits in paper {
s.horizon.hdr_ui_nits = nits;
s.horizon.hdr_paper_white = nits;
assert_eq!(item.slider(&s), Item::Horizon(H::HdrPaperWhite).slider(&s));
}
}
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
/// the mode; any change of the mode drops the hold, and HDR is asked for again.
#[test]
fn a_held_hdr_start_is_said_on_the_page_and_a_mode_change_tries_again() {
use crate::gfx::{HdrOutput, output::tests};
let mut s = Settings::default();
s.horizon.hdr = HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0".into());
let (mut caps, reading) = tests::window(Some(HdrOutput::Pq), Some(true));
caps.held = true;
let out = tests::snapshot(&s.horizon, Default::default(), caps, reading);
let hdr = Item::Horizon(H::Hdr);
let setting = h(
H::Hdr,
Cost::None,
"Lava, fire and sunlight shine brighter than white.",
);
let held = super::row(setting, &s, &out);
assert_eq!(held.chosen.as_deref(), Some(HDR_HELD));
assert_eq!(held.why, None);
assert_eq!(hdr.label(&s, &out).plain(), "HDR: Auto");
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s, &out), Some(HDR_HELD));
// Not held, the row says what HDR does.
caps.held = false;
let free = tests::snapshot(&s.horizon, Default::default(), caps, reading);
assert_eq!(super::row(setting, &s, &free).chosen, None);
// Any step of the mode, either way, tries again.
for back in [false, true] {
let mut s = s.clone();
hdr.cycle(&mut s, back, FB, &out);
assert_ne!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(s.horizon.hdr_failed_start, None);
}
}
/// Undo never brings the hold back: held Auto, changed to On (HDR tried again), then
/// undone, is Auto without the hold. With the mode left alone, it keeps the hold as it is.
#[test]
fn undo_never_restores_a_held_hdr_start() {
let mut screen = GraphicsScreen {
framebuffer: FB,
output: NONE,
};
let mut opened = Settings::default();
opened.horizon.hdr = HdrMode::Auto;
opened.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let mut s = opened.clone();
let out = offering(true, Some(true));
Item::Horizon(H::Hdr).cycle(&mut s, true, FB, &out);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::On, &None)
);
screen.undo(&mut s, &opened);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::Auto, &None)
);
// Another row changed and undone: the hold stays.
let mut s = opened.clone();
s.horizon.fxaa = !s.horizon.fxaa;
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}");
}
}
+5 -4
View File
@@ -206,8 +206,9 @@ fn display(light: vec3<f32>) -> vec4<f32> {
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
var light = lit(f.rgb, w.a, shows, true).light;
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
// 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);
}
@@ -275,8 +276,8 @@ fn backdrop(w: vec3<f32>) -> vec3<f32> {
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
// (1 - c) of the world, light above white included. Where the world is at or below white, with
// UI Brightness at paper white, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = textureLoad(hud, at, 0).rgb;
@@ -17,6 +17,7 @@
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
@@ -33,6 +34,10 @@ pub enum Step {
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// A step's test picture for HDR output: the step, its nits and where the controls start (a
/// share of the height).
pub type Picture = (Step, f32, f32);
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
@@ -43,6 +48,30 @@ pub enum Outcome {
Cancel,
}
/// The window as calibration found it: calibration turns it fullscreen for its test pictures,
/// and gives it back as it was.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowState {
Fullscreen,
Maximized,
Windowed,
}
impl WindowState {
pub fn of(fullscreen: bool, maximized: bool) -> Self {
match (fullscreen, maximized) {
(true, _) => Self::Fullscreen,
(false, true) => Self::Maximized,
(false, false) => Self::Windowed,
}
}
/// On leaving calibration: whether the window leaves fullscreen, and whether it is
/// maximized again after (leaving fullscreen gives back its restored size on Windows).
pub fn leaving(self) -> (bool, bool) {
(self != Self::Fullscreen, self == Self::Maximized)
}
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
@@ -53,8 +82,11 @@ pub struct Calibration {
dragging: bool,
}
/// Height of the band of controls at the bottom, in GUI pixels.
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
const BAND: f32 = 84.0;
/// Each further line of the hint, which wraps within the window less this at each side.
const HINT_LINE: f32 = 10.0;
const HINT_MARGIN: f32 = 10.0;
impl Step {
/// The slider's steps past its first: the settings' own.
@@ -115,18 +147,24 @@ impl Step {
struct Layout {
top: f32,
hint: Vec<Text>,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
/// The band grows up by the lines `step`'s hint takes past its first.
fn layout(font: &Font, step: Step, (w, h): (f32, f32)) -> Layout {
let hint = Text::literal(lang::get(step.hint_key()));
let hint = font.split(&hint, w - 2.0 * HINT_MARGIN);
let more = hint.len().saturating_sub(1) as f32 * HINT_LINE;
let top = (h - BAND - more).floor();
let cx = (w / 2.0).floor();
let by = top + 58.0;
let by = top + more + 58.0;
Layout {
top,
slider: (cx - 100.0, top + 32.0, 200.0),
hint,
slider: (cx - 100.0, top + more + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
@@ -159,6 +197,9 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn layout(&self, font: &Font, size: (f32, f32)) -> Layout {
layout(font, self.current(), size)
}
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
@@ -184,18 +225,17 @@ impl Calibration {
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
/// The current step's test picture in a GUI of `size`.
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
(
self.current(),
self.values[self.step],
layout(0.0, h).top / h.max(1.0),
self.layout(font, size).top / size.1.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = layout(w, h);
let l = self.layout(&atlas.font, (w, h));
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
@@ -205,16 +245,10 @@ impl Calibration {
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
for (i, line) in l.hint.iter().enumerate() {
let y = l.top + 18.0 + i as f32 * HINT_LINE;
ui::centered_text(batch, atlas, line, 0.0, y, w, rgba(160, 160, 160, 255));
}
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -261,8 +295,14 @@ impl Calibration {
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
pub fn click(
&mut self,
font: &Font,
cursor: (f32, f32),
size: (f32, f32),
drag: bool,
) -> Outcome {
let l = self.layout(font, size);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
@@ -336,6 +376,14 @@ mod tests {
const NONE: EffectiveOutput = EffectiveOutput::NONE;
fn font() -> Font {
Font::metrics(std::path::Path::new("/nonexistent/horizon-test-assets"))
}
/// At the default GUI scale of the game's first window (854 x 480 at 2).
fn picture(c: &Calibration) -> Picture {
c.picture(&font(), (427.0, 240.0))
}
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
@@ -344,14 +392,14 @@ mod tests {
let mut c = Calibration::new(&s, &NONE);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert!((picture(&c).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(picture(&c).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
@@ -370,10 +418,10 @@ mod tests {
for (output, peak, paper_white) in [(&reporting, 1000.0, 400.0), (&NONE, 1000.0, 203.0)] {
let mut c = Calibration::new(&s, output);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).0, Step::Peak);
assert_eq!(c.picture(1.0).1, peak);
assert_eq!(picture(&c).0, Step::Peak);
assert_eq!(picture(&c).1, peak);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, paper_white);
assert_eq!(picture(&c).1, paper_white);
}
}
@@ -392,14 +440,14 @@ mod tests {
];
let mut c = Calibration::new(&Settings::default(), &NONE);
for (i, expected) in expected.iter().enumerate() {
assert_eq!(c.picture(1.0).0, STEPS[i]);
assert_eq!(picture(&c).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![c.picture(1.0).1];
let mut seen = vec![picture(&c).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = c.picture(1.0).1;
let v = picture(&c).1;
if seen.last() != Some(&v) {
seen.push(v);
}
@@ -422,7 +470,7 @@ mod tests {
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s, &NONE);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
assert!((picture(&c).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
@@ -435,14 +483,89 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, 10_000.0);
assert_eq!(picture(&c).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 9500.0);
assert_eq!(picture(&c).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 1950.0);
assert_eq!(picture(&c).1, 1950.0);
}
/// A hint wider than the window less its margins wraps, the band grows up by the lines
/// it adds, and the test picture ends where the band drawn starts.
#[test]
fn the_hint_wraps_and_the_band_grows_by_its_lines() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::Enter);
let hint = Text::literal(lang::get(Step::PaperWhite.hint_key()));
let wide = font.width_text(&hint);
let size = (wide + 2.0 * HINT_MARGIN, 240.0);
let one = c.layout(&font, size);
assert_eq!(one.hint.len(), 1);
assert_eq!(one.top, 240.0 - BAND);
let narrow = (wide * 0.6, 240.0);
let wrapped = c.layout(&font, narrow);
assert_eq!(wrapped.hint.len(), 2);
assert!(
wrapped
.hint
.iter()
.all(|l| font.width_text(l) <= narrow.0 - 2.0 * HINT_MARGIN)
);
assert_eq!(wrapped.top, 240.0 - BAND - HINT_LINE);
// Under the hint's lines, the controls keep their place in the band.
assert_eq!(
wrapped.slider.1 - wrapped.top,
one.slider.1 - one.top + HINT_LINE
);
assert_eq!(
wrapped.next.1 - wrapped.top,
one.next.1 - one.top + HINT_LINE
);
for size in [size, narrow] {
let (_, _, band) = c.picture(&font, size);
assert_eq!(band * size.1, c.layout(&font, size).top);
}
}
/// The Peak step's patch (a square of a tenth of the screen, centred above the band,
/// `hdr.wgsl`) stays above the band even on the narrowest GUI with every hint wrapped:
/// its side is no more than the band's top.
#[test]
fn the_test_pictures_stay_above_a_grown_band() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
for step in STEPS {
for (w, h) in [(320.0, 240.0), (200.0, 240.0), (427.0, 240.0)] {
let (shown, _, band) = c.picture(&font, (w, h));
assert_eq!(shown, step);
let side = (0.1 * w * h).sqrt();
assert!(
side <= band * h,
"{step:?} at {w}x{h}: {side} > {}",
band * h
);
}
c.key(KeyCode::Enter);
}
}
/// Leaving calibration gives the window back as it found it.
#[test]
fn leaving_gives_the_window_back_as_it_was() {
use WindowState as W;
assert_eq!(W::of(true, false), W::Fullscreen);
assert_eq!(W::of(true, true), W::Fullscreen);
assert_eq!(W::of(false, true), W::Maximized);
assert_eq!(W::of(false, false), W::Windowed);
// (leave fullscreen, maximize again)
assert_eq!(W::Fullscreen.leaving(), (false, false));
assert_eq!(W::Maximized.leaving(), (true, true));
assert_eq!(W::Windowed.leaving(), (true, false));
}
}
+7 -84
View File
@@ -12,6 +12,7 @@ use std::{
};
use wgpu::util::DeviceExt;
mod animate;
pub mod cit_glint;
mod pack_text;
pub mod pictures;
@@ -100,13 +101,13 @@ pub struct HudAtlas {
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' Polytone screen sprite modifiers.
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
@@ -1427,32 +1428,7 @@ impl HudAtlas {
}
if full {
// Official Recipes artwork from Just Enough Items, under MIT.
for (name, png) in [
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
),
] {
for (name, png) in crate::jei_sprites::SPRITES {
let (width, height, rgba) = decode_bytes(png)?;
images.push((format!("horizon/jei/{name}"), width, height, rgba));
}
@@ -2895,65 +2871,12 @@ impl HudRenderer {
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&horizon_shaders::CoreProgram>,
shader: Option<&crate::core_shader::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
});
}
pub fn animate_sprites(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
tick: u64,
) {
let Some(texture) = &self.atlas_texture else {
return;
};
for (rect, animation, previous) in &mut self.sprite_animations {
// `SpriteContents.AnimationState`: the frame from the mcmeta's
// list, blended into the next one by the sub-frame's thousandths
// when the sprite interpolates.
let blend = animation.blend(tick);
let state = (blend.frame, blend.next, blend.millis);
if state == *previous {
continue;
}
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = if blend.millis == 0 {
animation.resized_pixels(blend.frame, w, h)
} else {
let current = animation.resized_pixels(blend.frame, w, h);
let next = animation.resized_pixels(blend.next, w, h);
let millis = blend.millis;
current
.iter()
.zip(next)
.map(|(&a, b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
as u8
})
.collect()
};
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: (rect.u0 * texture.width() as f32).round() as u32,
y: (rect.v0 * texture.height() as f32).round() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
w,
h,
&pixels,
);
*previous = state;
}
}
/// Stage `patches` into the shared atlas texture.
pub fn upload_patches(
&self,
@@ -0,0 +1,61 @@
//! The GUI atlas's animated sprites, frame by frame (vanilla's or a Polytone `mode`'s).
impl super::HudRenderer {
/// Shows each animated sprite's frame: its mcmeta's, or what its Polytone `mode` picks.
pub(crate) fn animate(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
tick: u64,
animator: &crate::gfx::animate::SpriteAnimator,
) {
let inputs = animator.mode_inputs();
let inputs = Some(&inputs);
let Some(texture) = &self.atlas_texture else {
return;
};
for (rect, animation, previous) in &mut self.sprite_animations {
// `SpriteContents.AnimationState`: the frame from the mcmeta's
// list, blended into the next one by the sub-frame's thousandths
// when the sprite interpolates; or what a Polytone `mode` picks.
let blend = animation.blend_with(tick, inputs);
let state = (blend.frame, blend.next, blend.millis);
if state == *previous {
continue;
}
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = if blend.millis == 0 {
animation.resized_pixels(blend.frame, w, h)
} else {
let current = animation.resized_pixels(blend.frame, w, h);
let next = animation.resized_pixels(blend.next, w, h);
let millis = blend.millis;
current
.iter()
.zip(next)
.map(|(&a, b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
as u8
})
.collect()
};
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: (rect.u0 * texture.width() as f32).round() as u32,
y: (rect.v0 * texture.height() as f32).round() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
w,
h,
&pixels,
);
*previous = state;
}
}
}
@@ -393,7 +393,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
@@ -414,7 +414,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
@@ -5,7 +5,7 @@
use std::path::Path;
use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use crate::core_shader::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
@@ -64,7 +64,7 @@ pub(super) fn load_core(
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
match crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
Ok(program) => {
log::info!("the resource pack's {name} shader is used");
Some(Arc::new(program))
@@ -132,7 +132,7 @@ impl PackText {
count: None,
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -175,7 +175,7 @@ impl PackText {
}),
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
@@ -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);
}
}
Loaded 100 of 943 files, more files were not shown because too many files have changed in this diff. Show more