Compare commits

...
Author SHA1 Message Date
Ludovic Marie aedfd10c93 Merge pull request #12220 from NikitaMGrimm/hdr-frame-rebuild
🤖 Agent: The HDR float frame follows HDR, Smooth Gradients and the picked pack at once by rebuilding the renderer; all of it is behind HORIZON_HDR_FRAME=float, so the default path only pays one bool check per frame.
2026-11-08 08:19:20 +01:00
Ludovic Marie f64f181d1d Merge pull request #12319 from glitchsnake/fix/11651-spawn-finalize
🤖 Agent: EntityType.create spawns (frogspawn tadpoles, infested silverfish, allay duplication, villager breeding, sniffer eggs, the thunder skeleton horse...) skip finalizeSpawn's rolls; I fixed the lightning witch, which vanilla still finalizes with CONVERSION (Villager.java:748).
2026-11-08 07:57:06 +01:00
Ludovic Marie fe020bb113 Merge pull request #12314 from NikitaMGrimm/warden-digging-test
🤖 Agent: The warden digging test ticks the warden at age 1, since the client moves tickCount on before it ticks; it had failed on main since #12110 wherever the vanilla data is prepared.
2026-11-08 07:45:06 +01:00
Ludovic Marie 0ae694bb66 Merge pull request #12308 from voidsipp/fix/round6-12001
🤖 Agent: F3 shows the debug overlay on the title screen and F3+F6 opens the debug options over the panorama, closing back to the title without grabbing the pointer, as TitleScreen allows.
2026-11-08 07:45:02 +01:00
Ludovic Marie 25b7c04314 Merge pull request #12307 from NikitaMGrimm/tnt-piston-powder
🤖 Agent: A piston push of primed TNT applies the blocks it moves through (powder snow, soul sand, slime), as the piston's Entity.move does. Stacked on #12290.
2026-11-08 07:44:59 +01:00
Ludovic Marie 1a8589a0dd Merge pull request #12305 from NikitaMGrimm/hdr-oit-written
🤖 Agent: Improved Transparency writes light on the float frame too, behind the same HDR_WRITE_LIGHT override. Stacked on #12302.
2026-11-08 07:44:55 +01:00
Ludovic Marie 7176e0df23 Merge pull request #12303 from Solenopsisbot/perf/worldgen-shape-capture
🤖 Agent: The decoration shape-capture vector is kept per region and restored on every path, including errors, instead of a fresh Vec per update.
2026-11-08 07:44:51 +01:00
Ludovic Marie fad4b0f5dd Merge pull request #12302 from NikitaMGrimm/hdr-vis-written
🤖 Agent: On the float frame the visibility buffer writes light itself; behind the HDR_WRITE_LIGHT override, false on the default frame. Stacked on #12282.
2026-11-08 07:44:48 +01:00
Ludovic Marie 8ca3dc19dd Merge pull request #12299 from d3xc0/android-touch-1
🤖 Agent: Adds the touch control layout model (schemes, 14 controls, load/save with defaults); nothing calls it yet, so players see no change.
2026-11-08 07:44:44 +01:00
Ludovic Marie 26ee23d376 Merge pull request #12296 from voidsipp/fix/round6-12089
🤖 Agent: An option button's pointer cursor follows hover only, while its focus still highlights it (AbstractWidget.handleCursor tests hover), and a click on a disabled button no longer focuses it.
2026-11-08 07:44:40 +01:00
Ludovic Marie fe524fefb2 Merge pull request #12293 from J5Casey/braid/screen-input-act
🤖 Agent: Screen drag and wheel get v2 hooks that lend the game mutably, heard just before v1; v1 keeps its signature and still hears the turn unless v2 cancels it or closes the screen.
2026-11-08 07:44:36 +01:00
Ludovic Marie bb26451389 Merge pull request #12292 from voidsipp/fix/round6-12218
🤖 Agent: A resting item in a lava cauldron burns: each lava cauldron its box meets (the cauldron's inside shape, not the air above) applies LAVA_IGNITE and its own lavaHurt, stopping once the item is gone; netherite stays immune, as the new oracle shows.
2026-11-08 07:44:33 +01:00
Horizon agentandClaude Opus 5.5 2e2635f83d Lightning: the witch from a villager is still finalized
Villager.thunderHit converts with finalizeSpawn(level, difficulty,
EntitySpawnReason.CONVERSION, null); only Pig.thunderHit skips it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:33:26 +01:00
Horizon agent aee2c83c2f Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:29:43 +01:00
Ludovic Marie 6ef71901bf Merge pull request #12316 from horizon-rust/fix/test-batch-starting-feedback
🤖 Agent: the batch-starting line now goes to the /test command's own source inside the command (TestBatchSummaryDisplayer); the test-instance block still uses the server source. Lifecycle stays 95/99: its 4 if-loaded records are an oracle artifact.
2026-11-08 07:13:17 +01:00
Ludovic Marie da954fba54 Merge pull request #12315 from horizon-rust/fix/mob-scores-until-removal
🤖 Agent: mob_died no longer resets scores at the death; vanilla does it in onDestroyed, so unloaded corpses keep them. The #12185 shulkers get fixed UUIDs so /scoreboard players list can match at all.
2026-11-08 07:13:14 +01:00
LokiandAntigravity 4f54fcf47b mobs: skip finalizeSpawn for EntityType.create spawns (fixes #11651)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 15:13:09 +09:00
Ludovic Marie 22e6e5c8a4 Merge pull request #12242 from NikitaMGrimm/entity-own-random
🤖 Agent: A new item's own random now makes its UUID (Mth.createInsecureUUID, the first two draws), then bobOffs, yaw and motion in that order, instead of a process-wide clock-seeded UUID; I merged main (the seed-sync gaps diagnostic this PR retires stays out).
2026-11-08 07:09:07 +01:00
Ludovic Marie d77d31fff1 Merge pull request #12245 from Solenopsisbot/perf/tick-scratch-reuse
🤖 Agent: Block contact scratch lists are kept on Game and restored on every Result path; I replaced extend(drain(..)) with append, the clippy error that failed verify (clippy -D warnings passes now).
2026-11-08 07:09:04 +01:00
Ludovic Marie 2c04e1ba85 Merge pull request #12211 from rdbtCVS/perf/beehive-stay
🤖 Agent: bees_stay_in_hive's night and rain answer is kept for the hive's turn instead of building the mob world per occupant.
2026-11-08 07:09:00 +01:00
Nikita Maximilian Grimm edddce4485 Merge remote-tracking branch 'origin/main' into warden-digging-test 2026-11-08 07:08:38 +01:00
Ludovic Marie 6ebe4a33c3 Merge pull request #11867 from MrCloudy2/fix/passengers-players
🤖 Agent: `execute on passengers` finds the players riding a vehicle on a server (the player in place, then the hub guests by their vehicle); I merged main, whose new mob-children arm now follows this PR's player_passengers, so both players and mob riders are found.
2026-11-08 07:04:32 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4018f7925e Warden digging test ticks the warden at age 1, as the client does
Since #12110 a warden's client tick plays the heartbeat when its age is
a multiple of the beat delay. The test ticked a new warden at age 0, so
it heard a heartbeat. The client loop moves the age on before it ticks,
as vanilla's `tickCount++` does, so the game never ticks at age 0.

Fixes #12312

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:00:07 +01:00
Ludovic Marie cbb2b99022 Merge pull request #12285 from voidsipp/fix/round6-12201
🤖 Agent: An export that fails on a missing lib/tzdb.dat now names the Java to repair, matched only on the FileNotFound/NoSuchFile line for that exact path; other failures keep their message.
2026-11-08 06:58:24 +01:00
Ludovic Marie 7fdce634b4 Merge pull request #12283 from k3yh/loot-world-pack-ids
🤖 Agent: /loot and pack functions take the world's packs' loot tables: the id list comes from the loaded tables (root plus pack overlay) and is refreshed on /reload.
2026-11-08 06:58:20 +01:00
Ludovic Marie 7d084fc8aa Merge pull request #12282 from NikitaMGrimm/hdr-write-light
🤖 Agent: On the float frame every shader writes its own light; all of it sits behind the HDR_WRITE_LIGHT pipeline override, false on the default frame, so the SDR path compiles it out. Stacked on #12220 and #12247.
2026-11-08 06:54:56 +01:00
Ludovic Marie edb0905d0f Merge pull request #12267 from J5Casey/braid/menu-links
🤖 Agent: Mod menu types can show slots of other containers (native_menu_links) instead of mirroring them each tick; stacked on #12246, lands after it.
2026-11-08 06:54:53 +01:00
Ludovic Marie f6dda117a8 Merge pull request #12265 from voidsipp/fix/round6-12129
🤖 Agent: Other crates' records, native mods included, now reach latest.log from INFO with their target prefixed; they still stay off stderr, so only the log file grows.
2026-11-08 06:54:49 +01:00
Ludovic Marie 5c0ac57986 Merge pull request #12263 from J5Casey/braid/loot-custom-data
🤖 Agent: match_tool and loot predicates now test minecraft:custom_data as NbtUtils.compareNbt(pattern, data, true), with JSON patterns converted as JsonOps does; compare_nbt moved to horizon-world for both callers.
2026-11-08 06:54:45 +01:00
Ludovic Marie a12e378c46 Merge pull request #12262 from k3yh/loot-give-guests
🤖 Agent: /loot give gives to every target player, each guest with their own state in place via for_player, as LootCommand.playerGive does.
2026-11-08 06:54:41 +01:00
Ludovic Marie c1e81319cb Merge pull request #12259 from J5Casey/braid/player-break-api
🤖 Agent: native_player_break_block breaks a block as a given player through the game's own player-break path (spectator, adventure can_break, spawn protection, native_destroy_block, loot, wear, stats).
2026-11-08 06:54:38 +01:00
Ludovic Marie 7232e9f6bd Merge pull request #12255 from echo-ilovech3ss/fix-11887-mob-absorption
🤖 Agent: Death protection and milk removal clamp a mob's absorption to its remaining max_absorption (onAttributeUpdated), instead of subtracting 4 per Absorption level.
2026-11-08 06:54:34 +01:00
Ludovic Marie fe63866acc Merge pull request #12254 from rison1337/perf/tight-terrain-occlusion
🤖 Agent: CPU terrain occlusion tests each section's padded mesh bounds instead of the whole 18-block cell, so empty space no longer keeps a hidden section drawn; sections without bounds keep SECTION_BOX.
2026-11-08 06:54:31 +01:00
Ludovic Marie 4c66382f4e Merge pull request #12252 from masa-masa-0314/feat/realms-slot-pictures
🤖 Agent: Realms world slots draw their own pictures and template pages arrive page by page. Cost to note: the four 1024-tall slot pictures now live in the HUD atlas for every player, about one 1024-tall shelf of GPU memory; a follow-up should load them only while Realms screens are open.
2026-11-08 06:54:28 +01:00
Ludovic Marie 604e9cb82f Merge pull request #12251 from k3yh/loot-set-name-components
🤖 Agent: /loot now applies set_name and set_components to its drops, as a chest's roll already did, and names each drop by its display name in the feedback.
2026-11-08 06:54:24 +01:00
Ludovic Marie 205a2387c3 Merge pull request #12248 from vnsmoreira/attributes/timelines
🤖 Agent: Ports vanilla's timelines, keyframe tracks and all 32 easings into horizon-world with Java's float/double steps; nothing in the game calls it yet, only the two new oracles' probes.
2026-11-08 06:54:21 +01:00
Ludovic Marie 1d56d438df Merge pull request #12247 from NikitaMGrimm/hdr-light-function
🤖 Agent: One hdr_mark_light function serves the display and resolve passes; a GPU test checks it against the old code for every 8-bit mark and both easing modes.
2026-11-08 06:54:17 +01:00
Ludovic Marie 7a87965ec8 Merge pull request #12246 from J5Casey/braid/craft-grid-3
🤖 Agent: The recipe_sources native hook lets recipe placement into a mod menu type's grid take ingredients from containers the mod names; stacked on #12240 and #12233, lands after them.
2026-11-08 06:54:13 +01:00
Ludovic Marie 1334feb5fa Merge pull request #12244 from TheMrZZ/fix/h268-global-selector-order
🤖 Agent: An unbounded arbitrary @e walks the joined families in insertion order (EntityLookup.getEntities), falling back to the old order if the walk fails; it builds one count map per such query, other selectors keep their path.
2026-11-08 06:54:09 +01:00
Ludovic Marie fb75635ec3 Merge pull request #12243 from NikitaMGrimm/fn-tick-time
🤖 Agent: #minecraft:tick functions now run before the levels tick, so they read the game time from before the tick, and /schedule'd ones run in ServerLevel.tickTime once the clock has moved, as MinecraftServer.tickChildren orders them.
2026-11-08 06:54:06 +01:00
Horizon agent af4a313bc7 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-08 06:51:10 +01:00
Horizon agentandClaude Opus 5.5 9b0c6f48ea Drop the second passengers arm left by the main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:50:09 +01:00
f030d9c807 HDR float frame: Improved Transparency writes light
With Improved Transparency on, the float frame's default lane no longer
stays marked: each see-through layer converts its own colour to light
(hdr_written) before it is weighted and accumulated, so the composite
lays light over the light behind it, as the forward passes do. Text,
shadows, weather and the border keep their source gain, as they do in
the forward passes; cracks keep their multiply blend outside the
accumulation, since it scales the light under them. Only a shader pack
keeps the frame marked now. The identity harness's float OIT session is
no longer an identity case; Improved Transparency with HDR off and on
the 16-bit frame are, and fail unless the option changed the picture.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:50:04 +01:00
Nikita Maximilian Grimm bf3191ed83 Merge branch 'tnt-fall-powder' into tnt-piston-powder 2026-11-08 06:46:41 +01:00
Nikita Maximilian Grimm 631eef433b Merge remote-tracking branch 'origin/main' into tnt-fall-powder
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 06:46:31 +01:00
SolenopsisbotandCodex 7ba6d345b0 perf: reuse decoration shape capture
Retain captured shape-update ticks across decoration queries while preserving ordering and error propagation.

Co-Authored-By: Codex <[email protected]>
2026-11-08 16:46:01 +11:00
Ludovic Marie 2d29c7f8e9 Merge pull request #12200 from TiggerTakkyTTT/fix/datapack-biome-ambient-particles
🤖 Agent: Datapack-added or replaced biomes keep their minecraft:visual/ambient_particles, so ambient particles use the world pack's biome instead of the prepared vanilla table.
2026-11-08 06:44:40 +01:00
Ludovic Marie 76bfbf1ad8 Merge pull request #12238 from itarqos5/feat/redstone-diff-harness
🤖 Agent: tools only: redstone_diff.py runs the same random circuit on vanilla and Horizon and compares every tick; nothing players run changes.
2026-11-08 06:44:36 +01:00
Arthur BlackandClaude Opus 5.5 87b5ae27a6 Touch layout model for on-screen controls
- Add touch::layout: control schemes, built-in controls, per-scheme placements and the saved settings file
- Visibility follows the scheme unless the player shows or hides a control; left-handed mirroring; D-pad jump/sneak swap
- Load falls back to defaults and sanitizes off-screen or non-finite values; save writes through a temp file
- Nothing uses it yet: the touch controls that read it come in the next pull requests
- Adapted from Starling (MIT, by Vasysik); credited in THIRD_PARTY_NOTICES.md

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-07 21:33:35 -08:00
voidsip 28acf8303c Exercise the title submenu control through debug keys 2026-11-07 23:32:55 -06:00
Ludovic Marie 2a66276bcc Merge pull request #12232 from Solenopsisbot/perf/defaults-ticket-fastpath
🤖 Agent: perf: unchanged ticket sources skip renormalisation, and item defaults load once per root; I fixed the fast path to compare the dragon arena too (it broke the watched-dragon-arena test).
2026-11-08 06:29:15 +01:00
Ludovic Marie 38f9ce31e7 Merge pull request #12210 from rdbtCVS/perf/streaming-cover
🤖 Agent: update records that the cover was refreshed at its start, so each waiting chunk no longer compares the ~200-entry extra areas list every tick.
2026-11-08 06:29:11 +01:00
Horizon agentandClaude Opus 5.5 67f84002d3 redstone_diff: start the game through lab.game_env
tests/test_game_home.py requires every script that starts the game to use it
(scratch folders, no audio) instead of setting HORIZON_NO_AUDIO by hand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:28:25 +01:00
Horizon agentandClaude Opus 5.5 97f4aa5e7f Tell /test's source when a batch starts, inside the command
Vanilla's TestCommand.trackAndStartRunner adds a TestBatchSummaryDisplayer
holding the command's source; GameTestRunner.start runs the first batch
inside the command, and testBatchStarting sends
"Starting environment <env> batch <n>" with sendSuccess(.., true). Horizon
queued the line as a server console broadcast for the next tick, so `/test
run` answered only "Running 1 test(s)..." (world_test_def 94/96,
world_test_def_lifecycle 6 of its 10 differing records, as #11992 noted).

A runner a command starts now sends the line through that command's source
while the command runs (`feedback`). A test instance block's runner keeps the
server's source (TestInstanceBlockEntity.runTest), as do later batches, which
start from the tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:28:23 +01:00
Horizon agentandClaude Opus 5.5 3b89ded9f9 Keep a dead mob's scores until its body is removed
Vanilla resets an entity's scores in ServerLevel.EntityCallbacks.onDestroyed
(Scoreboard.entityRemoved), when the entity is removed for good: a mob's body
twenty ticks after LivingEntity.die, never when it is unloaded. Horizon reset
them in mob_died, at the death. A mob killed in a frozen world and then
unloaded kept its scores in vanilla and lost them here, so a later
`/scoreboard players list` named 32 fewer holders (command_exec, after
#12185's shulker command damage case).

mob_died no longer resets them; score_destroyed_entities already resets every
removed entity's scores once a tick, and keeps an unloaded one's.

#12185's 32 shulkers had random UUIDs, which that list then named: Java and
Horizon could never print the same list. The case now gives them fixed
UUIDs, so the record is exact again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:28:23 +01:00
Nikita Maximilian Grimm 5c9bf7ad11 Merge branch 'tnt-fall-powder' into tnt-piston-powder 2026-11-08 06:27:20 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 de12dbd275 Slime slows piston-pushed TNT before the blocks it is in act
`Entity.applyEffectsFromBlocks` runs the supporting block's `stepOn`
before the blocks the entity is inside, so slime slows TNT before a
bubble column beside it lifts it. TNT's own tick had them the other
way round, and the piston push shares the same steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:27:20 +01:00
J5CaseyandClaude Opus 5.5 957475f644 Native hooks: screen drag and wheel v2 with the game lent mutably
native_screen_mouse_dragged_pre and native_screen_mouse_scrolled_pre take
&self because their NativeScreenView borrows the game, so a mod can cancel
a drag or a wheel turn over a screen but can't click a slot there.
Mouse Tweaks-style mods (Mouse Tricks) inject into the private
inventory_drag_move and tweak_scroll instead and pin one Horizon commit.

Add native_screen_mouse_dragged_pre_v2 and
native_screen_mouse_scrolled_pre_v2 with &mut Game: the view's id and
pointer come by value, the rest as v1's. Each fires at v1's point, just
before v1, which hears the screen as v2 left it and nothing of an input
v2 canceled. Cancelling does what cancelling v1 does. v1 is unchanged.

Fragment tools/native-hook-fragments/screen_mouse_input_v2.json; native
bodies filled by `cargo run -p xtask -- mod-api`.

Fixes #12286

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-07 23:26:15 -06:00
Horizon agentandClaude Opus 5.5 33e0bf2aec Use append for the later cells (clippy::extend_with_drain)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:26:10 +01:00
Horizon agent dca25b7662 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 06:26:10 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 964e31a3c3 HDR float frame: the visibility buffer writes light
With HORIZON_VISBUFFER=1, the float frame's default lane no longer stays
marked: the visibility buffer's resolve writes its solid pixels' light
as fs_opaque does (solid_light), so the frame is written whole. The
identity harness's float visbuffer session is no longer an identity
case; the visibility buffer with HDR off and on the 16-bit frame are,
drawn flat so that the buffer runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:26:03 +01:00
Ludovic Marie da4e8d8d49 Merge pull request #12225 from NikitaMGrimm/block-entities-wait-entities
🤖 Agent: Block entities and scheduled ticks wait until their chunk's entities have loaded, as ServerLevel.isPositionTickingWithEntitiesLoaded gates them; the entities_pending set is cloned once per call.
2026-11-08 06:25:58 +01:00
Ludovic Marie efe5e70188 Merge pull request #12212 from rdbtCVS/perf/player-squares
🤖 Agent: With more than 8 players the level keeps them by 256-block square, built when a spawn pass first asks; smaller servers keep the plain scan.
2026-11-08 06:25:54 +01:00
Ludovic Marie 901e640591 Merge pull request #12209 from rdbtCVS/perf/creaking-heart-world
🤖 Agent: The creaking heart builds the mob world only for its update (about once a second), with server_tick split into start and update.
2026-11-08 06:25:51 +01:00
Ludovic Marie e7c443cdf6 Merge pull request #12208 from rdbtCVS/perf/spawner-delay
🤖 Agent: A spawner's delay counts down without building a level view when no player is in range, the common case, and builds it only when it may spawn.
2026-11-08 06:25:47 +01:00
Ludovic Marie baf9a32a0e Merge pull request #12207 from rdbtCVS/perf/structure-start-cache
🤖 Agent: The structure start cache keeps 262,144 chunks instead of 65,536: a few MB more for far fewer regenerated starts while exploring.
2026-11-08 06:25:43 +01:00
Ludovic Marie 854a5dc246 Merge pull request #12206 from rdbtCVS/perf/decoration-rank
🤖 Agent: Decoration ranks are unique, so only chunks without a rank are measured and the queue is a heap that pops until the workers are full, instead of a full sort per update.
2026-11-08 06:25:38 +01:00
Ludovic Marie 8c672e7f9e Merge pull request #12219 from NikitaMGrimm/item-escape-gravity
🤖 Agent: An item pushed out of a solid block takes its gravity step before the escape push (#12018), and the new command_exec case pins two ticks whose height and motion are exact whatever the random push speed.
2026-11-08 06:25:35 +01:00
Ludovic Marie 4a1f7dfad4 Merge pull request #12215 from NikitaMGrimm/test-randoms-sync
🤖 Agent: Harness-only seed sync: the seed sync proof is its own isolated command_exec group, so its fixed seeds cannot leak into the groups after it.
2026-11-08 06:25:31 +01:00
Ludovic Marie e6d44af84e Merge pull request #12236 from martipops/settings/shell-graphics
🤖 Agent: The Graphics page describes its rows and the shell draws and drives them; I merged main's three-state GPU Culling into the new test helpers (focus_on, key), graphics_ui tests pass.
2026-11-08 06:25:27 +01:00
Ludovic Marie 23ea8d66a6 Merge pull request #12235 from itarqos5/fix/setblock-notify-after-shape-updates
🤖 Agent: /setblock and /fill notify neighbours after all shape updates (BlockInput.place, ServerLevel.updateNeighboursOnBlockSet), so a wire beside a block that pops off ends as a cross; I merged main (fill's note-block normalisation kept) and marked the two resource-backed tests #[ignore], as the other HORIZON_TEST_RESOURCES tests are, since CI has no resources.
2026-11-08 06:25:24 +01:00
Nikita Maximilian Grimm 8abbc6b9e4 Merge remote-tracking branch 'origin/main' into tnt-fall-powder 2026-11-08 06:23:27 +01:00
voidsip f2fe9c1dfe Keep title submenu debug-options handling unchanged 2026-11-07 23:22:01 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 6a11db7ef7 Frame identity harness: the float visibility buffer session draws flat
The visibility buffer draws only frames drawn flat, which frames without
a pack are by default only on Metal and some Intel drivers, so elsewhere
float_visbuffer compared the forward passes. It now sets
HORIZON_FLAT_DRAWS=1 and fails unless the game built the buffer. The
module docs list the sessions added on this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:19:47 +01:00
voidsip 7a5d306949 Show title-screen debug information and options 2026-11-07 23:19:38 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 66d06e23f7 A spent piston push still applies TNT's block effects
Once `limitPistonMovement` has used the tick's 0.51, `Entity.move`
returns at once, but `moveEntityByPiston` still runs
`applyEffectsFromBlocks`, so a second piston's push still lets slime
under the TNT slow it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:18:11 +01:00
voidsip 417677fdda Match cauldron contact tolerances and repeated damage callbacks 2026-11-07 23:15:03 -06:00
k3yhandClaude Opus 5.5 8721db0e28 /loot and a pack's functions name the world's packs' loot tables
The commands' minecraft:loot_table ids came from the resource root's
folders, so /loot refused a world data pack's table, and a pack's
function naming one was dropped at load. ResourceOrIdArgument looks the
id up in the server's registries, which hold the world's packs. The ids
are now the world's loot table map (Items::loot_table_ids): set when the
world opens and again before admit_store parses the packs' functions.

New command corpus group: loot world pack tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:09:20 -05:00
Nikita Maximilian Grimm 190c17fc7a Merge branch 'tnt-fall-powder' into tnt-piston-powder 2026-11-08 06:01:36 +01:00
Ludovic Marie 9e1abc64d1 Merge pull request #12169 from abit-foggy/codex/studio-c1-c2-topbar-timeline
🤖 Agent: Studio's top bar and timeline are Horizon-only and off unless Studio is opened; the only gameplay touch is the fov/play_input hooks, which read the new state only while Studio is open.
2026-11-08 05:58:14 +01:00
Ludovic Marie d1782f8954 Merge pull request #12233 from J5Casey/braid/craft-grid-1
🤖 Agent: A mod menu's crafting grid crafts through the crafting table's own recipe lookup (lan_crafting), so mod grids and the table agree on every recipe.
2026-11-08 05:58:10 +01:00
Ludovic Marie 00c184e250 Merge pull request #12239 from voidsipp/fix/gateway-item-corpus
🤖 Agent: A replaced end gateway's cached server cooldown is discarded, so a new gateway at the same spot does not inherit its teleport cooldown; covered by the new gateway_replacement oracle.
2026-11-08 05:58:06 +01:00
Ludovic Marie c4b55f1113 Merge pull request #12237 from TheMrZZ/fix/h197-loaded-arrow-mob-can-break
🤖 Agent: A mob-owned arrow may break a block only with its own saved can_break predicate and mob_griefing on; predicates with nbt, components or predicates conjuncts fail closed instead of matching on blocks alone.
2026-11-08 05:57:53 +01:00
Ludovic Marie 7054def60b Merge pull request #12234 from voidsipp/fix/empty-stored-enchantments
🤖 Agent: An enchanted_book with empty stored_enchantments keeps the component instead of dropping it, as ItemEnchantments.EMPTY still saves.
2026-11-08 05:57:49 +01:00
Ludovic Marie 776a3b08a0 Merge pull request #12229 from NikitaMGrimm/fix-fluids-drag-test
🤖 Agent: The item flowing-water test now expects vanilla's float drags (0.98F/0.8F widened to double), the values Entity.handleFluid really produces.
2026-11-08 05:57:45 +01:00
Horizon agent f79643821e Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:57:41 +01:00
Ludovic Marie 3b52978005 Merge pull request #12224 from Solenopsisbot/perf/hotpath-microalloc
🤖 Agent: Rebased onto main: the hot-path buffers (command args, protocol buf, redstone, json folder) are reused instead of reallocated, with the args tests covering the reused path.
2026-11-08 05:57:41 +01:00
Ludovic Marie f4c7a94ad6 Merge pull request #12214 from rdbtCVS/perf/sulfur-grid
🤖 Agent: All geysers of one block-entity pass share one mob grid kept until the mob list's next write; the effect and push loops restamp it only because they change no mob box.
2026-11-08 05:57:37 +01:00
Ludovic Marie 252f02e02a Merge pull request #12213 from rdbtCVS/perf/cart-grid
🤖 Agent: A cart's push and pickup read neighbouring carts and mobs from grids; any mutable access to the mob list bumps its stamp, and with native mods loaded both full scans stay, since a hook can move entities unseen.
2026-11-08 05:57:32 +01:00
Nikita Maximilian Grimm 1d267df0b3 Merge remote-tracking branch 'origin/main' into tnt-fall-powder 2026-11-08 05:57:31 +01:00
Horizon agent 9651982e21 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:57:28 +01:00
Ludovic Marie 3e3a0ffa10 Merge pull request #12216 from rdbtCVS/fix/guest-hit-mob
🤖 Agent: A guest's effect and sting hits now credit the mob that swung, matching LivingEntity.hurtServer's attacker; stacked on #12204, lands after it.
2026-11-08 05:57:28 +01:00
Ludovic Marie 11a7d2cd89 Merge pull request #12204 from rdbtCVS/perf/event-listeners
🤖 Agent: Melee hits and game events read the mobs near them from a grid instead of walking every mob; the grid is rebuilt on the mob list's next write (JoinList stamp), so a mob spawned mid-tick is still found.
2026-11-08 05:57:25 +01:00
Horizon agent 2415fb9e91 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands/execute.rs
#	tools/lan_e2e.py
2026-11-08 05:57:04 +01:00
Nikita Maximilian Grimm 6f395191c2 Merge branch 'tnt-fall-powder' into tnt-piston-powder 2026-11-08 05:56:08 +01:00
Horizon agentandClaude Opus 5.5 01eefdcc63 Ticket fast path: compare the dragon arena too
A watcher arriving at or leaving the End fight changes only Sources.dragon;
the unchanged-sources skip ignored it, so the arena never got (or lost) its
TicketType.DRAGON levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:56:02 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 cafee2f14d A piston push of primed TNT applies the block effects
`Entity.move(MoverType.PISTON)` uses up TNT's pending stuck multiplier
(without scaling the push), reads its fall for powder snow's top, adds
to that fall and resets it on landing, and applies the block speed
factor. `moveEntityByPiston` then runs `applyEffectsFromBlocks`, so
powder snow it is pushed into makes it stuck, and slime under it slows
it. Horizon did none of this, so in a chunk that does not tick
entities pushed TNT kept its motion in powder snow and on soul sand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:55:41 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 bd8e0da359 Powder snow's top holds falling TNT until it reaches it
After a fall over 2.5, `PowderSnowBlock.getEntityInsideCollisionShape`
is the 0.9-high top, so the snow only makes the TNT stuck once a step
reaches that top. A TNT passing 2.5 just above the snow used to be made
stuck early, lose its fall and sink through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:53:44 +01:00
J5CaseyandClaude Opus 5.5 bff8ecf203 Loot item predicates match minecraft:custom_data as vanilla
Block loot's `match_tool` (horizon-items) and the loot engine's
ItemPredicate (`item_matches`: `filtered`, entity `equipment`) ignored
`predicates."minecraft:custom_data"`, so any stack passed. They now test
it as `CustomDataPredicate` does (NbtPredicate.matches ->
CustomData.matchedBy -> NbtUtils.compareNbt(pattern, data, true)): a typed
NBT subset, lists matched element by element, absent custom data read as
`{}`, the pattern SNBT or a JSON object converted as DataFixerUpper does.

`compare_nbt` moves down to horizon-world (re-exported from nbt_path) so
the loot engine can use it, and a killer's equipment keeps its custom data
for entity loot predicates.

New harness loot_custom_data: a data pack's dirt table with a pool per
pattern behind match_tool, `loot give @s mine` with sticks carrying
different custom data, 66/66 records equal to vanilla. The loot engine's
`filtered` path gets the same vanilla matrix as a horizon-world test.

Fixes #12196

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-07 22:47:46 -06:00
k3yhandClaude Opus 5.5 affb2b6924 /loot give gives to every target player
The give branch of cmd_loot skipped every target but the player in
place, so the console's /loot give reached nobody on a dedicated
server, and a command block's /loot give @p gave nothing in single
player. Each target now goes through for_player, as cmd_give does
(LootCommand.playerGive gives to every target ServerPlayer).

New lan_e2e check: loot_give_guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:45:11 -05:00
J5CaseyandClaude Opus 5.5 d450bc3b5d Mod menu types: linked slots that show other containers' slots
A content mod's menu type may declare `"links": {"from", "count"}`: those
slots show the slots of block containers a native mod names each tick the
menu is open (`native_menu_links(player, menu, pos, links)`), in order, as
real slots of the menu. Clicks, shift-clicks and drags run on the server's
menu engine and move the containers' own items, a changed container is
saved and its comparators update, and the block entity never holds the
linked slots. Linked slots with nothing behind them hold nothing and are
left out of clicks, as a hidden slot is.

This is what a crafting station's side panel needs (Tinkers' Construct's
shows the chest beside it); a tab per neighbour is a menu button that
changes what the mod links for that player.

Part of #12198.

Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:44:11 -06:00
voidsip 3ac68d69d3 Keep option button cursors tied to pointer hover 2026-11-07 22:34:17 -06:00
Shreyas 22c6c667a2 Clamp a mob's absorption to maximum on death protection and milk removal 2026-11-08 10:00:00 +05:30
SolenopsisbotandCodex d91706378e perf: reuse block tick scratch buffers
Retain per-tick block contact, random tick, and entity-inside vectors on Game while restoring scratch across result exits and preserving vanilla ordering.

Co-Authored-By: Codex <[email protected]>
2026-11-08 15:26:47 +11:00
SolenopsisbotandCodex 92e96819f5 perf: skip repeated defaults and ticket normalization
Single-flight concurrent item-default loads and avoid rebuilding unchanged chunk-ticket sources while preserving normalization and portal expiry semantics.

Co-Authored-By: Codex <[email protected]>
2026-11-08 15:26:02 +11:00
Nikita Maximilian Grimm f9ed804bb7 Merge main into hdr-frame-rebuild: main has fixed the datapack test this PR's checks failed on 2026-11-08 05:25:58 +01:00
Horizon agentandClaude Opus 5.5 2394ee861d Mark the setblock order tests as needing prepared resources
CI has no HORIZON_TEST_RESOURCES, so they panicked there; like the other
resource-backed game tests they now run with --ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:25:17 +01:00
SolenopsisbotandCodex f54f5c41b6 perf: remove repeated hot-path allocations
Reuse ordered tick scratch, decode protocol varints directly, avoid selector character buffers, single-flight concurrent JSON folder loads, and keep redstone neighbor ordering on the stack.

Co-Authored-By: Codex <[email protected]>
2026-11-08 15:25:13 +11:00
voidsip 048eb18683 Burn dropped items that touch lava cauldrons 2026-11-07 22:23:14 -06:00
k3yhandClaude Opus 5.5 ace3883402 /loot applies set_name and set_components
The items loot engine, which /loot rolls with, skipped both functions:
the item came out without its name, lore, potion or book contents,
while a chest rolling the same table had them. set_name sets the
component its target names, as a chest's roll does. set_components
applies the patch as ItemStack.applyComponents does and reads the
stack back as saved components are read, so a potion's contents make
it a potion. The feedback names each drop by getDisplayName.

New command corpus group: loot set name and components.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:23:12 -05:00
rison1337andCodex 86e57cb74c Merge remote-tracking branch 'upstream/main' into perf/tight-terrain-occlusion
Co-Authored-By: Codex <[email protected]>
2026-11-08 09:22:59 +05:00
rison1337andCodex 33c96ac1f2 Use padded mesh bounds for CPU terrain occlusion
Reuse resident section bounds for phase-one retests and deferred occlusion tests, retaining a one-block total pad and the existing full-cell fallback.

Co-Authored-By: Codex <[email protected]>
2026-11-08 09:22:24 +05:00
2bc3726b4e HDR float frame: every shader writes light on the default lane
Under HORIZON_HDR_FRAME=float, with HDR on, no shader pack, and neither
Improved Transparency nor the visibility buffer requested, the world's
shaders write their own light (hdr_written) instead of a mark, and the
passes that only write marks, the hand's unmark and remark and the
resolve pass are skipped. A frame is all written or all marked. TAA's
history is dropped when that or the HDR parameters it holds change.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:22:16 +01:00
Horizon agent 3a05ee18c6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/README.md
2026-11-08 05:22:03 +01:00
Horizon agent e1f9a8c096 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/graphics_ui.rs
2026-11-08 05:22:03 +01:00
Horizon agent 0b2270dd45 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
2026-11-08 05:22:02 +01:00
Ludovic Marie 4dbb00502f Merge pull request #12110 from kacpherek/parity/batch-b
🤖 Agent: Fixed: `MobWorld` has no `end` field, so the fox village stroll now reads `dimension_type.has_fixed_time`, as `Level.isBrightOutside` does; it builds with main merged (lib and tests).
2026-11-08 05:21:19 +01:00
Ludovic Marie 6c75463502 Merge pull request #11714 from arslanarm/shaderpacks/fixture-terrain-notes
🤖 Agent: Requeued after a main merge (clean); the earlier clippy failure was in `horizon-core/src/density/interpolation.rs`, which this PR doesn't touch.
2026-11-08 05:21:15 +01:00
Horizon agent c471036d29 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 05:21:11 +01:00
Ludovic Marie 41de65db6e Merge pull request #11764 from rdbtCVS/fix/far-ring-brake-thresholds
🤖 Agent: Requeued after a main merge (clean); the earlier failure was main's native mod API stamp (`native_level_play_sound`) at that moment, not the brake thresholds.
2026-11-08 05:21:11 +01:00
Horizon agent 1c15d2b68c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:21:03 +01:00
Horizon agent 0eb8b8ec26 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 05:21:01 +01:00
Horizon agent 1d0621ac38 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:50 +01:00
Horizon agent 1ebc813852 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:47 +01:00
Horizon agent 59994ad2a2 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:45 +01:00
Horizon agent 9a62e99376 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:42 +01:00
Horizon agent 0b10ddd665 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:39 +01:00
Horizon agent 406ac72d0a Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 05:20:35 +01:00
Ludovic Marie e2116fb8c2 Merge pull request #11783 from TheMrZZ/fix/fill-note-block-instrument
🤖 Agent: Requeued after a main merge: `fill_note_instrument_tests` sits beside main's commands.rs tests.
2026-11-08 05:17:35 +01:00
voidsip acb17d5694 Explain missing Java timezone data during preparation 2026-11-07 22:16:52 -06:00
Ludovic Marie 65a2bf4613 Merge pull request #11792 from MSedgeMC/fix/camel-breed-parent
🤖 Agent: Requeued after a main merge (clean): a wounded or ridden camel still refuses to start breeding, as in `Camel.canFallInLove`.
2026-11-08 05:16:30 +01:00
Ludovic Marie eb69b3986b Merge pull request #11860 from Joansitoh/fix/11853-patch-log-scroll
🤖 Agent: Requeued after a main merge; the earlier verify failure was a 60 s timeout in `test_entity_shadow_ownership` on a loaded runner, not this PR's launcher scroll change.
2026-11-08 05:16:26 +01:00
Ludovic Marie 389224abec Merge pull request #11879 from TheMrZZ/fix/h155-fork-function-feedback
🤖 Agent: Requeued after a main merge: the fork-feedback corpus case and its flag are kept beside main's new command corpus cases.
2026-11-08 05:16:22 +01:00
Ludovic Marie 3bea613053 Merge pull request #11890 from MrCloudy2/fix/rider-vehicle-teleport
🤖 Agent: Requeued after a main merge: `lan_e2e.py` keeps both sides' checks; the codec base still isn't reset by `handleTeleportEntity`.
2026-11-08 05:16:18 +01:00
Ludovic Marie a9d293cb18 Merge pull request #12012 from TheMrZZ/fix/h262-enderman-absolute-anger
🤖 Agent: Requeued after a main merge: only persist.rs's test modules conflicted; the absolute anger time read is unchanged.
2026-11-08 05:16:15 +01:00
Ludovic Marie cd8ebdff23 Merge pull request #12032 from MSedgeMC/fix/melee-home-chase
🤖 Agent: Requeued after a main merge: `melee_continue_tests` sits beside main's new goals.rs tests; the home check in `canContinueToUse` is unchanged.
2026-11-08 05:16:11 +01:00
Ludovic Marie 6edc5de20f Merge pull request #12162 from KBroneZ/claude/carry-1-20-folders
🤖 Agent: Requeued after a main merge: in report.rs, main's installed-data-pack branch comes before the carried-mod branch, which still marks 1.20 layouts Partial; port-pack checks clean.
2026-11-08 05:16:08 +01:00
Ludovic Marie 314edb02fc Merge pull request #12166 from KBroneZ/claude/datapack-support-text
🤖 Agent: Requeued after a main merge: all 86 lang files were merged key by key with main, keeping main's new strings and this PR's data pack text.
2026-11-08 05:16:04 +01:00
Ludovic Marie 5e2b6c675f Merge pull request #12177 from TheMrZZ/fix/h171-inline-feature-parse
🤖 Agent: Requeued after a main merge: only game.rs's test-module list conflicted (`inline_feature_parse_tests` beside main's new ones).
2026-11-08 05:16:00 +01:00
Ludovic Marie 01c53f23d2 Merge pull request #12193 from UnQuis/feat/opaque-ice-clean
🤖 Agent: Requeued after a main merge: the `graphics_ui.rs` conflict kept Opaque Ice's switch, and main's tri-state GPU culling no longer has a plain on/off arm there.
2026-11-08 05:15:57 +01:00
voidsip 40aed431b6 Keep native mod diagnostics in the game log 2026-11-07 22:13:47 -06:00
Nikita Maximilian Grimm 4c3cb9a51b Merge hdr-light-function-main (one mark-to-light function, on main) into the written lane 2026-11-08 05:10:32 +01:00
Nikita Maximilian Grimm 2052b918ec Merge remote-tracking branch 'origin/main' into tnt-fall-powder
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 05:09:46 +01:00
Horizon agentandClaude Opus 5.5 c23236bceb Fox village stroll: isBrightOutside reads the dimension's fixed time
MobWorld has no end field. Level.isBrightOutside is false in every
dimension with a fixed time (the Nether and the End).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4ca78266a5 Primed TNT keeps a fall distance and powder snow holds it
Primed TNT is an Entity in vanilla, so `checkFallDamage` adds each step
down, narrowed to a float, to its double fall_distance, which resets on
landing or in water and is saved and read and written by /data. Powder
snow uses that distance: over 2.5 it holds TNT on a 0.9-high top, and
otherwise TNT goes in and is stuck (0.9/1.5/0.9) like in a cobweb. The
stuck multiplier is used on the next move, which then zeroes motion.

Double and float lists in saved entity data now store -0.0 as 0.0, as
`DoubleTag.valueOf` does, so TNT resting on a block reads Motion 0.0d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:14 +01:00
Ludovic Marie cf9569747b Merge pull request #12191 from TheMrZZ/fix/h260-level-world-border
🤖 Agent: queued. The level in place reads its own saved `world_border` when the world opens, before any command, so a first `/worldborder set` is no longer overwritten later.
2026-11-08 05:06:47 +01:00
Ludovic Marie 423abdd863 Merge pull request #10280 from MSedgeMC/fix/jukebox-stop-vibration
🤖 Agent: jukebox stop now captures was_playing before clearing j.playing, so the stop vibration fires once. On main merge 904b3036a, sculk, server_player_sculk, server_wool_vibration and mob_command_damage_vibration pass on playhorizon. command_exec's only 2 mismatches are main's gateway_item case (same 16819/16821 on bare main b13dbf068).
2026-11-08 05:06:42 +01:00
Horizon agent 8f0a7f4494 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-08 05:05:11 +01:00
Horizon agent 4b38881802 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-port-pack/src/report.rs
2026-11-08 05:04:10 +01:00
Horizon agent 7daecbbce2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/graphics_ui.rs
2026-11-08 05:03:21 +01:00
TheMrZZandCodex 1f5cd1e43b Preserve joined-family order in unbounded arbitrary entity selectors
Use the existing insertion traversal only at the qualified global selector branch.

Co-Authored-By: Codex <[email protected]>
2026-11-08 05:02:34 +01:00
Horizon agent d6be40bbf7 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:59:35 +01:00
Horizon agent e020e1d440 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:59:17 +01:00
Vinícius MoreiraandClaude Opus 5.5 01edf84d9f Environment attributes: timelines, keyframe tracks and easings
Ports net.minecraft.world.timeline and the keyframe machinery under it:
Timeline (clock, period, tracks, time markers, validateInternal),
AttributeTrack's codec (a modifier, override by default, and keyframes of
its argument), KeyframeTrack.validateKeyframes and validatePeriod,
KeyframeTrackSampler's looping segments and sampling,
argumentKeyframeLerp for every modifier, EasingType (cubic Bézier with
its Newton-Raphson and bisection solve) and all 32 Ease curves, with
Java's float and double steps (jmath, Mth's sine table). The layer stack
takes timelines as time-based layers that read their clock's ticks.

The new harness environment_timeline compares 3,464 cases with the pinned
client (every easing, the vanilla day, moon and early game tracks, and
synthetic and invalid tracks); environment_system now stacks the real
timelines between the biome and weather layers (36,000 lines).

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:59:14 -03:00
Horizon agent 8540619240 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
2026-11-08 04:59:13 +01:00
Horizon agent 97020c6abe Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:58:47 +01:00
Horizon agent 0a9ce82a0b Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:58:19 +01:00
Horizon agent 9dda4ce381 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 04:58:17 +01:00
Horizon agent 4d91ddc86c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-11-08 04:58:02 +01:00
Horizon agent e1ae4fb750 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 04:57:35 +01:00
Horizon agent d0dc8f1ad6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/goals.rs
2026-11-08 04:57:24 +01:00
Horizon agent 8ac6fa92a1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:57:04 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b1ebe3d6e0 Merge origin/main into fn-tick-time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:56:57 +01:00
masa-masa-0314andZCode 8485f4c0ef Realms: the slot pictures, the template pages and the minigame list
- The world slots' pictures follow RealmsWorldSlotButton.State: the
  minigame image (or presets/isles.png when it has none), the empty
  frame, a slot's own template image, then the slot's panorama. The
  atlas now keeps the four slot pictures (the three 1024-tall faces and
  isles.png) at their own size: they were excluded as title/background
  and presets, and even once let through were dropped by the 512-tall
  GUI texture cap.
- WorldTemplate/WorldTemplateList read like JsonUtils' getStringOr,
  getIntOr and getUriNullable: numbers read as their JSON text (some ids
  and player counts are numbers, some counters are strings), and an
  empty link is no link — the row icons are not drawn and no empty URL
  can be opened.
- The template list fetches one page per job and appends it as it
  arrives, chaining the next page until isLastPage or an empty page,
  instead of waiting for every page before drawing the first row.
- The minigame list (RealmsWorldsTab.switchToMinigame) returns to the
  Worlds tab on Back and Escape, not to a reset screen.

Tests: 2250 lib tests pass (71 realms), scoped clippy clean.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 11:56:47 +08:00
Horizon agent f38098f921 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:56:27 +01:00
J5CaseyandClaude Opus 5.5 87bb072d77 Native hook: recipe_sources, extra containers recipe placement takes from
`native_recipe_sources(player, menu, pos, sources)` lets a mod name
block containers (chests and the like, mod containers) that recipe
placement into a mod menu type's crafting grid may also take
ingredients from, after the player's inventory, as a crafting station
takes them from the chests beside it. No answer keeps vanilla's
placement.

Part of #12198.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:53:47 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 c7389334f1 A new item's own random makes its UUID, bob offset, yaw and motion
Vanilla's Entity takes its UUID from the first two draws of its own random
(Mth.createInsecureUUID); ItemEntity then draws bobOffs and the yaw from it,
and the constructor without a given motion draws the motion last. Horizon gave
every entity a clock-seeded UUID when first asked, drew a new item's motion
from a fresh random's first draws, its bobOffs from the particle random and a
loaded item's from the level's, and left the yaw at 0.

spawn_item_own makes all four from one own random in that order and replaces
dispenser::new_item_motion at its callers. A loaded or summoned item makes its
UUID (unless the tag has one) and bobOffs from its own random, so summoning no
longer shifts the level's random. Primed TNT from a block takes its UUID from
its own random too.

The seed sync proof now reads those UUIDs, the drop's rotation and motion, and
a TNT kick after a summoned item: 8/15 before, 15/15 after.

Fixes #12217

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:53:17 +01:00
itarqos5andClaude Sonnet 5.5 733d34582d tools: redstone_diff.py, whole redstone machines against vanilla one tick at a time
Runs hand-built circuits and random "soups" of redstone components through the real
tick loop in vanilla 26.3 (the bench mod's client, no server, no EULA) and in Horizon
(horizon-ctl), in a frozen world stepped with `tick step 1`, and prints the first tick
where the blocks differ and which ones. Narrowing: --near/--drop reduce a difference
to a few commands.

- RedstoneDiff + ServerLevelTickMixin in the bench mod snapshot the box before the
  first stepped tick and after each.
- `simulation blocks X1 Y1 Z1 X2 Y2 Z2` (horizon-ctl) reads the server's blocks, not
  the window's replica, which lags the server by design.
- agent.rs: `blocks_json`, shared by `blocks` and `simulation blocks`.

Only blocks are compared; entities are recorded, not diffed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 03:49:01 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol aac6bc2c2a HDR: one mark-to-light function for the display pass and the resolve pass
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 04:48:49 +01:00
itarqos5andClaude Sonnet 5.5 af6fc73a51 /setblock and /fill notify neighbours after the shape updates, as vanilla does
Vanilla places a command's block with UPDATE_CLIENTS | UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS
(2|256), so Level.setBlock notifies no neighbour. The shape updates run inside
setBlock; ServerLevel.updateNeighboursOnBlockSet follows afterwards (for /fill,
after every block is in). Horizon placed with UPDATE_ALL (3), so the neighbours
heard of the block before its shape updates ran.

That is visible when the notice makes a block pop off: a comparator with nothing
under it breaks at its first notice and takes an attached wall torch with it. In
vanilla the wire between them had already connected to the torch, so it ends as a
cross; in Horizon the torch was gone first and the wire ended as a dot.

Found by tools/redstone_diff.py (two of 48 random circuits differed on vanilla
26.3 vs Horizon, reduced to four commands); after the change 144 random circuits
over three seeds match vanilla tick for tick.

- Behaviours::update_neighbours_on_block_set ports ServerLevel.updateNeighboursOnBlockSet.
- place_state takes `command`: setblock and fill use the vanilla flags; clone keeps
  its own flow. The strict variants are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 03:44:52 +00:00
Ludovic Marie 8bf4597a57 Merge pull request #12167 from J1zeee/fix/12022-piston-push-velocity-restitution
🤖 Agent: queued. Piston restitution bounces the entity's own velocity and runs for server entities only; the ground flag comes from #12168 (queued first).
2026-11-08 04:44:37 +01:00
TheMrZZandCodex gpt-6.1-sol cc3d62a7cd Preserve loaded arrow can_break for Mob block permissions
Carry the actual top-level projectile predicate through ArrowState load/save
and consult the existing matcher only for resolved Mob arrows under both
rules. Refuse unsupported conjuncts and preserve other projectile/drop paths.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 04:43:07 +01:00
J5CaseyandClaude Opus 5.5 3042d4068b Recipe placement fills a mod menu type's crafting grid
The recipe book's place packet and Horizon's viewer transfer channel go
to an open mod crafting menu (`lan_mod_craft_apply`) over its block
entity's grid, as a click does, with a ghost recipe when the items are
missing; the client's `native_place_recipe` and `native_transfer_recipe`
accept such a screen, so JEI ports and the Recipes mod fill it as they
fill a crafting table.

Part of #12198.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:41:25 -06:00
Ludovic Marie e1f01aff0b Merge pull request #12161 from TheMrZZ/fix/h141-mannequin-save
🤖 Agent: queued. A mannequin saves `profile`, `hidden_layers`, `main_hand` and `pose` always, and no `Mob` fields, since `Mannequin` is an `Avatar`, not a `Mob`.
2026-11-08 04:40:29 +01:00
Ludovic Marie 9c24961df0 Merge pull request #11830 from TheMrZZ/fix/h122-named-predicate-random
🤖 Agent: queued. Literal `random_chance` predicates in commands now draw from the world's random, as `LootContext` gets `level.getRandom()`; main merged in.
2026-11-08 04:40:25 +01:00
Ludovic Marie bbabaf720b Merge pull request #12000 from masa-masa-0314/feat/realms-ui-batch
🤖 Agent: queued. Realms screens follow vanilla's layout, state and input; the owner-UUID change was reverted (it would orphan tamed pets in Horizon-made worlds), and `under_world` skips parts with ':', with a test.
2026-11-08 04:40:21 +01:00
Ludovic Marie 1e1c77126d Merge pull request #12112 from kacpherek/parity/batch-d
🤖 Agent: queued. Equippable assets for armor, wings, capes and camera overlays; the End flash now uses `random_source::create()` after main dropped `SoundEngine::unseeded`, with a skybox gate in `tick_end_flash`.
2026-11-08 04:40:18 +01:00
Ludovic Marie 933ab57aa1 Merge pull request #12172 from NikitaMGrimm/t3/scheduled-item-tick-pass
🤖 Agent: queued. An item a scheduled block tick drops ticks in that tick's entity pass, as `EntityTickList.forEach` runs after the block ticks; a boat's lily pad drop still waits.
2026-11-08 04:38:14 +01:00
Ludovic Marie c5067a988b Merge pull request #11934 from TheMrZZ/fix/h203-mob-absorption-max
🤖 Agent: queued. Health Boost and Absorption hooks run for every accepted effect and `add_effect` returns its result after them; after #11883, #11959 and #12108.
2026-11-08 04:37:54 +01:00
Ludovic Marie 3936cafd12 Merge pull request #12194 from Solenopsisbot/perf/measurement-tools
🤖 Agent: queued. Startup trace step stats (count, median, p95, RSS) with a test; the compression bench was taken out, since the zlib reuse it measures is not on main.
2026-11-08 04:37:20 +01:00
Ludovic Marie 3b6d2fc635 Merge pull request #11802 from itarqos5/feat/far-tile-packed-quads-clean
🤖 Agent: queued. Far tiles' quads take a quarter of the arena; the decode helpers only tests use are now `#[cfg(test)]`, so clippy is clean.
2026-11-08 04:37:15 +01:00
Ludovic Marie bd6025952f Merge pull request #12138 from itarqos5/feat/auto-gpu-culling
🤖 Agent: queued. GPU culling Auto turns it on where the CPU limits the frame and keeps it only if frame time improves; the client_lib.rs conflict kept both `autotune` and main's `random_source`.
2026-11-08 04:36:42 +01:00
martipops 98abaa9217 Merge remote-tracking branch 'upstream/main' into settings/shell-graphics 2026-11-07 22:36:12 -05:00
Ludovic Marie c6eef4b0b7 Merge pull request #12139 from TheMrZZ/fix/h210-container-name-components
🤖 Agent: queued. Styled container names stay as the `CustomName` tag in the live block model, so a styled chest name survives a break; main's spill test now compares tags.
2026-11-08 04:36:09 +01:00
Ludovic Marie 1fb469a969 Merge pull request #12115 from TheMrZZ/fix/h274-test-guest-view
🤖 Agent: queued. `/test pos` picks the block from a connected source player's view, as `TestCommand` reads `source.getPlayer()`; rustfmt fixed after the main merge.
2026-11-08 04:35:36 +01:00
Ludovic Marie 7adf3992a5 Merge pull request #12197 from martipops/settings/shell-graphics-features
🤖 Agent: queued. Undo, a row's cost and the greyed-row reason are generic shell features with tests; Extras gets no Undo, and Graphics moves onto them in the next PR.
2026-11-08 04:35:15 +01:00
Ludovic Marie 8629067aa4 Merge pull request #11811 from TheMrZZ/fix/chorus-projectile-drops
🤖 Agent: queued. Arrows and snowballs breaking a chorus flower now drop it via `ChorusFlowerBlock.onProjectileHit` (`destroyBlock` with drops); main merged in.
2026-11-08 04:34:42 +01:00
voidsip 9f6d28e257 Discard a removed gateway's server cooldown state 2026-11-07 21:32:07 -06:00
Ludovic Marie ec2c0c8d1f Merge pull request #12011 from NikitaMGrimm/hdr-tone-curve
🤖 Agent: queued. The HDR float frame's tone curve eases highlights into the peak instead of clipping; it is HDR-only, so the SDR path doesn't change.
2026-11-08 04:29:50 +01:00
Ludovic Marie 358c4e0c01 Merge pull request #12150 from RINNECODER/up2/glsl-exact-overload
🤖 Agent: queued. GLSL overload resolution now prefers an exact argument match over a conversion, as the GLSL spec ranks them, so packs that overload on int/float pick the right function.
2026-11-08 04:29:46 +01:00
Ludovic Marie 90b4e7d794 Merge pull request #11821 from vnsmoreira/attributes/layer-stack
🤖 Agent: queued. Environment attributes now come from the layer stack (dimension, then biome with blending, then weather) in `EnvironmentAttributeSystem`'s order; main merged in since the review.
2026-11-08 04:27:14 +01:00
Ludovic Marie 989afe1c00 Merge pull request #11870 from IwantHappiness/translate-ru-lang
🤖 Agent: queued. The remaining Russian strings in the launcher and the game are translated, and the keys match en_us, so no string falls back to English.
2026-11-08 04:27:10 +01:00
Horizon agent 0dc90c1d29 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:26:07 +01:00
Horizon agent 3e4f2a7305 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:25:21 +01:00
Horizon agent 1c9380fa56 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:24:12 +01:00
voidsip 6e5cd517d7 Elide default stored enchantments when saving enchanted books 2026-11-07 21:23:25 -06:00
Ludovic Marie 5a0906f5f4 Merge pull request #12051 from ilguerro/perf/11126-structure-starts-templates
🤖 Agent: queued. A template start is read back only when every child decodes; one unreadable child sends the whole start to #11652's regeneration, so old saves get nothing worse. I couldn't rerun the ignored `saved_starts_match_generated_layouts` here (no prepared resources on this host); your 7/7 run and the parity run decide.
2026-11-08 04:22:28 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b9404f32c3 Tick functions run before the game time moves on, and before that tick's scheduled functions
MinecraftServer.tickChildren runs the #minecraft:load and #minecraft:tick
functions before the world clocks and the levels tick; the Overworld's
ServerLevel.tickTime then raises the game time and runs the scheduled
functions due at the new time. Horizon raised the game time, the weather and
the day clock first, then ran the scheduled functions and the tick functions
after them, so a tick function read both clocks one tick ahead and a
`schedule ... 1t` from it waited a tick more.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:19:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 951fe53102 Seed sync: run the proof group as a harness of its own
A script that syncs runs on fixed seeds to its end, so in the full command-exec
corpus every group after the proof drew from them, at seeds fixed by whatever
came before. With one more proof case (#12217), "experience orb stuck", whose
orb rarely ends otherwise on random seeds, landed on such a seed and failed on
every run. The group is isolated now and runs as tools/test_seed_sync_parity.py;
the main corpus runs without the agent, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:16:28 +01:00
Nikita Maximilian Grimm 7abea17aa3 Merge remote-tracking branch 'origin/main' into fix-fluids-drag-test 2026-11-08 04:06:21 +01:00
Ludovic Marie 0f56e3165a Merge pull request #12227 from horizon-rust/fix/keyring-timeout-12179
🤖 Agent: keyring calls now give up after 20 s on a thread of their own; a call still stuck makes later ones fail at once, so the account lock is never held forever (tested with a keyring that never answers).
2026-11-08 04:05:55 +01:00
Horizon agent 3943b0e8a7 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:32 +01:00
Horizon agent f7a3c0f2e4 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:24 +01:00
Horizon agent 92b1ae1132 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:20 +01:00
Horizon agent 316752b706 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:16 +01:00
Horizon agent aa3919d3ae Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:15 +01:00
Horizon agent a1afb474a5 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:05:03 +01:00
Horizon agent fdb6ad8b8f Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:04:55 +01:00
Horizon agent 7f505e07dd Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:04:36 +01:00
Horizon agentandClaude Opus 5.5 98b4d049fa launcher: give up on a keyring that never answers (#12179)
A Secret Service daemon that never answers (two keyrings racing for the
D-Bus name, a locked collection with no working prompter) held the
account lock forever: Play stayed on "Starting…" and printed nothing.

Every keychain call now runs on a thread and gives up after 20 s
(Bounded): on Linux the secrets file takes writes as before, and a
sign-in that can't be read says so instead of "no owner". While a
given-up call is still stuck, later calls fail at once. Play prints one
stderr line per step, and `account list` prints why a check failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:04:29 +01:00
Horizon agent 32faed9621 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:04:03 +01:00
Horizon agent af61f6751e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 04:03:35 +01:00
abit-foggyandCodex a4df995446 fix(studio): localize timeline marker labels
Co-Authored-By: Codex <[email protected]>
2026-11-07 17:03:20 -10:00
Ludovic Marie 6c2498236d Merge pull request #12222 from horizon-rust/fix/new-world-packs-test-meta
🤖 Agent: queued first. Main's correctness job fails on this test since #12086 met the folder metadata gate (84f695992), and every PR that merges main fails on it; verify is green here.
2026-11-08 04:02:16 +01:00
Ludovic Marie 7ddbb41f86 Merge pull request #12141 from RINNECODER/up2/compute-workgroups-limit
🤖 Agent: queued. A dispatch over 65,535 workgroups per dimension (the wgpu limit) is now split into several dispatches with the base passed along, so packs whose compute pass sizes from the screen still run at 4K and up.
2026-11-08 03:58:06 +01:00
Ludovic Marie 4b38e08b7f Merge pull request #11950 from senjinthedragon/item-entity-material-maps
🤖 Agent: queued. LabPBR `_n`/`_s` maps now reach entities, items, block entities and the hand; `set_shared_material_maps` now runs after `set_custom_sky` in loading_window.rs, so a pack reload binds both.
2026-11-08 03:58:03 +01:00
abit-foggy 5c00a74d4e Merge remote-tracking branch 'upstream/main' into codex/studio-c1-c2-topbar-timeline
# Conflicts:
#	crates/horizon-client/src/game/play_keys.rs
2026-11-07 16:50:47 -10:00
abit-foggyandCodex 858f248202 fix(studio): align C1+C2 replay controls
Co-Authored-By: Codex <[email protected]>
2026-11-07 16:50:04 -10:00
Nikita Maximilian GrimmandClaude Opus 5.5 290560bc52 Expect the float drags in the item flowing-water test
#11794 made the item's water and air drag floats (0.99F, 0.98F), as
vanilla's are; the test still expected the f64 constants, so it fails
wherever the vanilla data is prepared.

Fixes #12221

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:25 +01:00
Horizon agentandClaude Opus 5.5 30c20a7a54 datapack test: give the new-world packs a pack section
folder_metadata_present (Pack.readPackMetadata) now requires a pack section
with a description, so the {} metadata in #12086's test hid both packs and
seed_create_world returned None. This failed every PR's verify on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:43:49 +01:00
Ludovic Marie f96d9e89b5 Merge pull request #10283 from MSedgeMC/fix/cloud-save
🤖 Agent: removing an entity's chunk now drops its area-effect clouds too (breath_clouds.retain), and saves keep entity_base_tag's Entity fields. On main merge 8b583a15e, save_roundtrip, entity_nbt_boolean, orb_saved_uuid, stand_fall_save, conversion_water_save, mob_pickup_save and potion_effect_icon pass. command_exec's only 2 mismatches are main's gateway_item case (same on bare main b13dbf068).
2026-11-08 03:41:29 +01:00
Nikita Maximilian Grimm a45b450e56 Merge remote-tracking branch 'origin/main' into block-entities-wait-entities 2026-11-08 03:40:46 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e6281842d8 Block entities and scheduled ticks wait for their chunk's entities to load
LevelChunk.isTicking and ServerLevel.isPositionTickingWithEntitiesLoaded
also need the chunk's entity section loaded (areEntitiesLoaded). It loads at
the end of a level tick, frozen or not (PersistentEntitySectionManager
.processPendingLoads), empty sections too. A chunk that loads now waits for
the end of the level's next pass before its block entities (bells, conduits,
gateways and sculk blocks included), moving pistons and scheduled ticks run.
The command harness's readiness waits mark their chunks loaded, as the
vanilla oracle's do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:35:18 +01:00
bulkandClaude Opus 5.5 842c094de7 Potent sulfur: find the mobs near a geyser from a grid
Every gassing geyser scanned every mob every 10 ticks, all on the same tick,
and every erupting one every tick. One grid of mob boxes is kept for the block
entity pass, until the mob list's next write; a geyser's own effects and
pushes move no mob, so the grid holds after them.

The mobs still get their effect and push in list order, and the gas reads
blocks only, so finding the reached mobs before any gets its effect changes
nothing.

(cherry picked from commit 2bad442a93, ported to the fields on Game)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:32:06 +00:00
bulkandClaude Opus 5.5 fc55599794 Credit the mob that swung for a guest's effect and sting hits
A cave spider's poison bite or a bee's sting on a guest named the mob
nearest to where the hit came from in X and Z, ignoring height, so a zombie
standing under the spider took the credit (the kill message, the hurt-by
advancement, the guest's revenge target). The event names the mob that
swung, as a plain melee hit does since the previous commit.

(cherry picked from commit 3ab80dc9d0, without its deletion of tools/bench_*.rs)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:32:06 +00:00
bulkandClaude Opus 5.5 12af27a781 Carts: read the carts and mobs near a cart from a grid
Each ticked cart compared its box with every cart, and a moving rideable cart
with every mob twice (and built the mob teams for each). A grid kept for the
tick answers in slot order, as the scans did. The cart grid follows each
ticked cart, is rebuilt when a cart is gone or added, and dropped after a
cart's command; the mob grid holds until the mob list's next write. With
native mods loaded, whose hooks can move either, the scans stay.

(cherry picked from commit 3613b40c6b, ported to the fields on Game)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:32:01 +00:00
Ludovic Marie bca5833f8d Merge pull request #12185 from Ragbie/fix/shulker-hurt-teleport-now
🤖 Agent: queued. A shulker's hurt teleport now runs inside the hurt, before the bullet's levitation, and riders get off where it stood (`unRide`); NoAI shulkers never teleport, as `teleportSomewhere` requires.
2026-11-08 03:31:45 +01:00
bulkandClaude Opus 5.5 ca0da4d9bb JoinList: count the writes, so a cache of the list can tell it is stale
A ListStamp names the list and how many times it was written through
`DerefMut` (every push, insert, retain or slot write). A cache built from
the list keeps the stamp and is rebuilt when the stamp differs; a swapped-in
list (another level's) has another name.

(part of commit 6b826d204a)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:31:42 +00:00
Ludovic Marie 4a8a101391 Merge pull request #12182 from TheMrZZ/fix/h283-built-in-component-delivery
🤖 Agent: queued. Four components (`provides_trim_material`, `provides_banner_patterns`, `can_place_on`, `can_break`) are now encoded in outgoing stacks with the synced registry IDs; anything unsupported drops the whole buffer, as before.
2026-11-08 03:31:41 +01:00
Ludovic Marie 07aab11e71 Merge pull request #12181 from TheApple05/codex/older-server-mapping-generation
🤖 Agent: queued. Every mapping value stays the same; the IDs the translators read come from generated pinned constants, and `--check` catches drift.
2026-11-08 03:31:37 +01:00
bulkandClaude Opus 5.5 fdd3d1e59c Stop walking every mob for each game event and melee hit
A game event posted outside the mob pass (a block placed, a door opened, an
explosion) looked through every mob of the level for the wardens and allays
that can hear it. The listener slots are now kept until the mob list is
written again (the previous commit's stamp), or another level's list is in
place.

Mob id lookups for game events, thorns and a hit's source go through the
mob index instead of a scan. A guest's melee event names the mob that
swung, so the hit credits that mob rather than the one nearest in x and z
to the player.

(cherry picked from commit 6b826d204a without its join_seq part, ported to the fields on Game)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:31:34 +00:00
Ludovic Marie bc21da03bc Merge pull request #12174 from NikitaMGrimm/saved-ticks-startup
🤖 Agent: queued. The server now waits for the saved tickets' chunks before the first tick (`prepareLevels`), so their saved ticks unpack at the time they were saved at; the wait gives up after 600 s.
2026-11-08 03:31:33 +01:00
Ludovic Marie 93fc4b2ba9 Merge pull request #12159 from itarqos5/tools/world-compare
🤖 Agent: queued. Tools only: `world_compare.py` and `perf_bench.py --seed`, no generator change; the seed report it answers is documented with the two seeds compared.
2026-11-08 03:31:30 +01:00
Ludovic Marie f3cc9257df Merge pull request #12080 from KBroneZ/claude/instances-folder
🤖 Agent: queued. `--folder` on the CurseForge `Instances` folder now finds its instances, and a Prism `instances` folder (same name where case is ignored) is not mistaken for it.
2026-11-08 03:31:26 +01:00
bulkandClaude Opus 5.5 8c1f6115d5 JoinList: count the writes, so a cache of the list can tell it is stale
A ListStamp names the list and how many times it was written through
`DerefMut` (every push, insert, retain or slot write). A cache built from
the list keeps the stamp and is rebuilt when the stamp differs; a swapped-in
list (another level's) has another name.

(part of commit 6b826d204a)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:31:25 +00:00
bulkandClaude Opus 5.5 069cc39f05 Carts: find the ticked cart at its slot and move it instead of cloning
push_from_ticked_cart searched the cart list for the ticked cart twice and
copied it in and out whole. The caller knows its slot: the ticked copy is
swapped into the slot for the push, and only the pushed cart is copied back,
over the copy it replaced (clone_from reuses its buffers). Lava hurting a cart
looks there first too.

Without mods a push changes velocities and riders, never the list, so the
cart is still in its slot after it (a debug assertion checks). With native
mods loaded a mount hook can change the list: the copies and the search by id
stay. The loot roll stays with the ticked copy, as #11526 made it: it comes
off before the swap and goes back after.

(cherry picked from commit 206905a2d5, without
the loot roll part of fb69aba537)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:31:22 +00:00
Ludovic Marie 0a407714e0 Merge pull request #12184 from sergue212fbright/fix/lod-slab-visibility
🤖 Agent: queued. Far terrain treats slabs as full cells like stairs, so a waterlogged slab keeps its own material instead of becoming water; the test walks every real slab state.
2026-11-08 03:31:22 +01:00
Ludovic Marie 3bb4e7827c Merge pull request #12164 from KBroneZ/claude/es-empty-shaderpacks
🤖 Agent: queued. An empty `shaderpacks/` (CurseForge and Sodium create it) no longer counts as a shader pack, for folders and archives alike; tests cover both.
2026-11-08 03:31:19 +01:00
Ludovic Marie 546eab04b7 Merge pull request #12168 from NikitaMGrimm/orb-piston-ground
🤖 Agent: queued. After every server push the ground flag is set to `verticalCollisionBelow` (`Entity.move` → `setOnGroundWithMovement`, which runs when `isLocalInstanceAuthoritative`), so a sideways push off a block clears it; #12167 builds on this.
2026-11-08 03:31:15 +01:00
Horizon agentandClaude Opus 5.5 a9fba955bd Leave the compression bench to the zlib reuse change
main still makes a new ZlibEncoder per record, so the bench would compare
two fresh encoders. It belongs with the change that adds the reuse (#12189).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:30:23 +01:00
Horizon agent e74f789659 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 03:28:10 +01:00
Horizon agent e02c201c97 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 03:28:04 +01:00
Horizon agentandClaude Opus 5.5 139e83fff8 Opaque Ice: own flag; IceBlock keeps frosted ice for fluids
Fluids read Info::ice through is_ice_class (instanceof IceBlock, frosted
ice included), so excluding frosted ice there changed fluid meshing with
the option off. Regular ice now gets its own opaque_ice flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:25:30 +01:00
Horizon agentandClaude Opus 5.5 1d6e5d01b5 Piston restitution: the entity's own velocity, server entities only, ground flag from #12168
Restitution bounces the entity's velocity (getDeltaMovement), not the push.
It runs for server entities only, since a player's client moves the player.
It runs even when the step is too small to apply, as Entity.move does.
The ground flag is left to set_pushed_on_ground (#12168), which follows
setOnGroundWithMovement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:22:00 +01:00
Horizon agent 2adedf5570 Merge commit 'refs/cg72/pr-12168' into HEAD 2026-11-08 03:20:30 +01:00
Ludovic Marie 4e6920a319 Merge pull request #11829 from senjinthedragon/entity-motion-objects
🤖 Agent: queued. `at_velocity` now covers boats, minecarts, armor stands, frames, paintings, TNT, falling blocks, block entities and moving pistons; the `falling_block_visible` doc comment is back on its function.
2026-11-08 03:19:38 +01:00
Ludovic Marie c214f59711 Merge pull request #12076 from TheMrZZ/fix/h246-storage-alias
🤖 Agent: queued. A wildcard `data modify storage` that fails part way keeps the edits made so far, as vanilla's in-place NbtPath edit does; main merged in.
2026-11-08 03:19:34 +01:00
Nikita Maximilian Grimm 5e7e654901 Merge remote-tracking branch 'origin/main' into item-escape-gravity
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 03:18:38 +01:00
J5CaseyandClaude Opus 5.5 7e4e421224 Native mods: break a block as a player (native_player_break_block)
A helper-only fragment adds `native_player_break_block(uuid, pos,
into_inventory)` (`Host::player_break_block`), so a vein-mining mod can
break blocks as its player without injecting into the private
`Game::break_block`. It runs the game's own player break
(ServerPlayerGameMode.destroyBlock): the player's checks, the
`destroy_block` hook, loot for the held tool and its enchantments, tool
wear, stats, exhaustion and experience. With `into_inventory` the block's
drops go to the player's inventory, the rest dropped from them.

The break now reports whether it removed the block, and a break nested in
another's hook keeps the outer break's own-event position. The acting
guest's spawn protection is kept for their turn so the helper can check
it while the hub is out of reach.

Fixes #12195

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:18:20 -06:00
Horizon agentandClaude Opus 5.5 719fa5e2bd rustfmt game.rs (verify failed on it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:17:03 +01:00
Horizon agentandClaude Opus 5.5 485323aaaf add_effect: keep the accepted result past the absorption and health boost hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:16:51 +01:00
Ludovic Marie 4a374440f4 Merge pull request #11883 from TheMrZZ/fix/h205-mob-health-boost
🤖 Agent: queued. Health Boost raises the max health modifier and clamps health down when it ends, as `HealthBoostMobEffect` does; #11934 builds on it.
2026-11-08 03:15:58 +01:00
Ludovic Marie b99e2087d3 Merge pull request #11848 from TheMrZZ/fix/h174-bell-block-entity
🤖 Agent: queued. Command reads keep the real bell block entity (and copper golem statue) instead of a synthesized one, so `data get block` matches vanilla's NBT; main merged in.
2026-11-08 03:15:55 +01:00
Ludovic Marie b84c97088f Merge pull request #11766 from TheMrZZ/fix/mob-effect-amplifier-codec
🤖 Agent: queued. Effect amplifiers decode as unsigned bytes (`Byte.toUnsignedInt`), so an amplifier of 200 no longer reads as -56; main merged in.
2026-11-08 03:15:51 +01:00
Ludovic Marie 57da7aecbc Merge pull request #11729 from TheMrZZ/fix/h195-command-damage-range
🤖 Agent: queued. `damage` predicate ranges compare the item's damage as `ItemDamagePredicate` does (range on damage, not durability); main merged in.
2026-11-08 03:15:47 +01:00
Ludovic Marie e93afc5ade Merge pull request #11728 from TheMrZZ/fix/summoned-leash-knot-lifecycle
🤖 Agent: queued. A summoned leash knot snaps to the block centre and dies without a fence, as `LeashFenceKnotEntity.survives` checks; main merged in.
2026-11-08 03:15:44 +01:00
Ludovic Marie a7296b75e0 Merge pull request #11708 from TheMrZZ/fix/h032-jukebox-saved-clock
🤖 Agent: queued. A jukebox without a song now saves no `ticks_since_song_started`, as `JukeboxSongPlayer` writes it only while playing; main merged in.
2026-11-08 03:15:40 +01:00
J5CaseyandClaude Opus 5.5 9d25e30101 Mod menu types: a crafting grid that crafts as the crafting table
A content mod's menu type may declare `"crafting": {"grid", "size",
"result"}`: those slots run vanilla's CraftingMenu logic on the menu
engine (live result, consumption and remainders on take, shift-click
crafting, recipe placement), over a grid the block entity keeps. The
result is the menu's own: the block entity never holds it, so hoppers,
comparators and a broken block can't dupe it, and the hub shows guests
the result the game made, again each tick the grid changed.

Part of #12198.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:15:16 -06:00
UnQuis 4543e2f454 Add an Opaque Ice performance option
A Horizon-only option, off by default, that draws regular ice opaque in the solid pass and culls faces between adjacent ice blocks. It follows the public description of Luxium's Opaque Ice and the design note in #11563; no Luxium source was read or copied.

Fixes #12192
2026-11-08 10:14:00 +08:00
Ludovic Marie 36584650ea Merge pull request #12152 from RINNECODER/up2/glsl-atomic-struct-copy
🤖 Agent: queued. Patch in vendored naga: copies of structs or arrays holding atomics go member by member, up to 64 scalars; larger ones stay whole copies, as noted.
2026-11-08 03:12:06 +01:00
Ludovic Marie aee2c63c3b Merge pull request #12143 from RINNECODER/up2/pack-image-formats
🤖 Agent: queued. The added formats (RGB10_A2, RGB10_A2UI, 16-bit SNORM) map to the wgpu formats `image_format` already binds; formats a GPU can't store into are still left out with a note.
2026-11-08 03:12:01 +01:00
Ludovic Marie ef23545cef Merge pull request #12126 from hunixcode/fix/translatable-fallback-literal
🤖 Agent: queued. A translatable with a fallback keeps the fallback as a literal, as `TranslatableContents` does, instead of looking it up as another key.
2026-11-08 03:11:58 +01:00
Ludovic Marie 92c708b3fe Merge pull request #12125 from hunixcode/fix/panel-unspecified-host
🤖 Agent: queued. The panel accepts LAN browsers when bound to 0.0.0.0 or :: only; a loopback bind still refuses them, so nothing new faces the network.
2026-11-08 03:11:55 +01:00
Ludovic Marie c6f935414d Merge pull request #12124 from itarqos5/perf/terrain-interpolant-packing
🤖 Agent: queued. Passing terrain's interpolants in 5 locations instead of 12; visbuffer reads `TerrainOut` only internally, so packs see no difference.
2026-11-08 03:11:51 +01:00
Ludovic Marie 73231e735d Merge pull request #12114 from p-stzhuk/fix/legacy-intel-metal
🤖 Agent: queued. The wgpu-hal change is in `third_party/`, gated to legacy Intel Metal; other GPUs take the same path as before.
2026-11-08 03:11:47 +01:00
Ludovic Marie a71408736f Merge pull request #12093 from vnsmoreira/packs/core-sky
🤖 Agent: queued. Pack `core/sky` and `core/stars` shaders apply only when a resource pack ships them; without one the sky pipeline is unchanged.
2026-11-08 03:11:44 +01:00
TiggerTakkyTTTandCodex fb4805072f Fix ambient particles for datapack biomes
Co-Authored-By: Codex <[email protected]>
2026-11-08 09:11:04 +07:00
Nikita Maximilian Grimm 111c22dd5e Merge remote-tracking branch 'origin/main' into test-randoms-sync 2026-11-08 03:09:04 +01:00
SolenopsisbotandCodex ce08c38337 perf: add opt-in startup and compression benchmarks
Measure startup step distributions and RSS, and compare reused versus fresh zlib streams with deterministic checksums.

Co-Authored-By: Codex <[email protected]>
2026-11-08 13:08:58 +11:00
Ludovic Marie 8d2900e6ce Merge pull request #12113 from kacpherek/parity/batch-e
🤖 Agent: queued. One cost: the stepped tracker paths add a Vec per tracked entity on the tracker tick; small, but it is the hot path, worth a look if MSPT moves.
2026-11-08 03:05:56 +01:00
Ludovic Marie cd1318bf02 Merge pull request #12111 from kacpherek/parity/batch-c
🤖 Agent: queued. Batch: sculk now hears bottles, golems, endermen and frames; portals dismount per vehicle tick as vanilla's `Entity.handlePortal` does.
2026-11-08 03:05:53 +01:00
Ludovic Marie 2f661c08ed Merge pull request #12109 from kacpherek/parity/batch-a
🤖 Agent: queued. Batch of server click answers; each case matches `Mob.interact`/`InteractionResult` and is in the batch's harnesses, which run in parity.
2026-11-08 03:05:49 +01:00
Ludovic Marie 1bf8766c1d Merge pull request #12103 from martipops/controller/direct-craft
🤖 Agent: queued. LT + A crafting a recipe repeatedly is new controller input; servers see the same clicks a mouse would send, so nothing server-side changes.
2026-11-08 03:05:46 +01:00
martipops 9f92bb23b4 Merge remote-tracking branch 'upstream/main' into settings/shell-graphics-features 2026-11-07 21:03:09 -05:00
Nikita Maximilian GrimmandClaude Opus 5.5 64f4bab6f3 Parity harnesses: unseeded randoms and entity ids can be synced between vanilla and Horizon
A test-only Java agent makes vanilla's RandomSupport.generateUniqueSeed step a
uniquifier of the calling thread's own, without the clock; HORIZON_TEST_SEEDS=1
does the same in Horizon's unique_seed. A command script's sync_randoms step sets
that uniquifier, the entity-id counter and the level's random in both engines,
and the "seed sync proof" group compares a drop's position, primed TNT's kick, a
dispensed item and the resting phase of four items exactly. The oracle cache
counts the agent jar as an input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:01:17 +01:00
Horizon agentandClaude Opus 5.5 52d1a0b475 lod/packed: from_bits and from_words are test-only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:00:41 +01:00
Horizon agentandClaude Opus 5.5 c0fe9adca6 End flash sound: random_source::create (SoundEngine::unseeded left main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:00:37 +01:00
Horizon agent 992ea05825 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 03:00:37 +01:00
Horizon agentandClaude Opus 5.5 2e11de8943 Compare spill test names as Tags (block_names holds the CustomName tag now)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:00:32 +01:00
Horizon agent 7351ad6d91 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-08 03:00:31 +01:00
Horizon agent e949fd4cc1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 02:58:50 +01:00
Horizon agentandClaude Opus 5.5 c1cdeb19d4 rustfmt client_lib.rs (verify failed on it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:50:17 +01:00
J1zeee b03127ab97 Merge fork/fix/12022-piston-push-velocity-restitution 2026-11-08 04:44:47 +03:00
Horizon agentandClaude Opus 5.5 1f9493c4fc Keep falling_block_visible's doc comment on it
The motion helpers went in between the comment and the function it describes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:44:31 +01:00
Nemotron 9524516752 Fix piston push not zeroing entity velocity in lazy chunks (#12022)
move_pushed applied the resolved movement to position only, leaving the
entity's velocity untouched. In a ticking chunk the entity's own tick
masks this (PrimedTnt::tick_among restitutes), but in a lazy chunk the
velocity survives: a primed TNT pushed by a piston into a wall, ceiling
or slime keeps 0.4 / -0.8 / -0.8 instead of vanilla's 0 / 0 / 0.64.

Add restitute_piston after apply_push: compare the post-piston-limit
movement (limited) with the post-collision result (resolved) and, as
Entity.move(MoverType.PISTON, …) does, zero blocked horizontal axes,
zero or slime-bounce the vertical axis, and update on_ground. Covers
Player, Guest and every Entity kind including PrimedTnt.

Add unit test a_pushed_tnt_loses_velocity_into_a_wall (needs prepared
resources) asserting the TNT's vx is zeroed by a wall.

Co-Authored-By: Nemotron <[email protected]>
2026-11-08 04:36:37 +03:00
Horizon agentandClaude Opus 5.5 a465c3efae rustfmt (verify failed on it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:30:39 +01:00
Horizon agent 904b3036ab Merge remote-tracking branch 'origin/main' into m10280 2026-11-08 02:29:41 +01:00
Horizon agentandClaude Opus 5.5 ebf06768f1 Merge main into #11950
Main moved build_renderer to game/loading_window.rs; the shared material maps
are set there now, after the custom sky as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:28:17 +01:00
Horizon agentandClaude Opus 5.5 fd03679d85 Merge main into #11829
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:27:35 +01:00
Horizon agentandClaude Opus 5.5 27ed9c66f7 Merge main into #11821
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:27:34 +01:00
Horizon agent 738861aae2 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 02:26:57 +01:00
Horizon agentandClaude Opus 5.5 c7ff85fdf4 Realms: keep the single-player owner UUID; skip drive and stream parts in downloads
- Merge main.
- The owner switch from the offline UUID to the account UUID (integrated.rs,
  lan/conn.rs, lan/playerdata.rs, net.rs, remote.rs) is left out: every world
  Horizon made names its player by the offline UUID, so tamed pets and other
  owner references would no longer know their player. It needs its own PR
  with a migration.
- A tar entry part with a colon (a drive prefix or an alternate data stream)
  would leave the world folder on Windows: skip it, with a test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:26:29 +01:00
Horizon agent 175c2d453a Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 02:25:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 673d5cff49 Read the escape case exactly now that the float drags have landed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:24:42 +01:00
Evrim CicekandClaude Opus 5.5 ca896819d7 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 04:24:28 +03:00
GlockSmithandOpenAI Codex GPT-6 0a8df72a16 Keep slab materials in distant terrain
Retain top and bottom slab states in LOD chunk summaries instead of
discarding dry slabs or replacing waterlogged slabs with water. Use the
same full-cell approximation as the existing partial-block filter.

Check real summaries, coarse reduction and emitted faces for every
prepared slab state. All 72 LOD tests pass, including resource-backed
coverage of 606 slab states across 101 materials.

Refs horizon-rust/horizon#12180.

Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-11-07 20:23:37 -05:00
TheMrZZandCodex 074aa1124f Encode four built-in item components for outgoing inventory stacks
Preserve typed holder and adventure-predicate payloads at the inventory bridge.

Co-Authored-By: Codex <[email protected]>
2026-11-08 02:15:11 +01:00
Horizon agent bf321a8425 Merge #11789 into #11792 (both add the last tests of camel.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/camel.rs
2026-11-08 02:14:01 +01:00
Horizon agent 2fdc1509ae Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 02:13:52 +01:00
TheApple05andCodex GPT-6 703c0ea18b Generate pinned adapter IDs and record regeneration recipes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-08 01:13:25 +00:00
abit-foggyandCodex GPT-6 d15f455027 fix(studio): show replay UI and release cursor
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 15:08:30 -10:00
Nikita Maximilian Grimm 23f0fd6675 Merge remote-tracking branch 'origin/main' into item-escape-gravity 2026-11-08 02:06:55 +01:00
Horizon agent 8b583a15e1 Merge remote-tracking branch 'origin/main' into m10283
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-08 02:06:02 +01:00
Florian ErnstandClaude Opus 5.5 48782a8231 Refuse a malformed inline /place feature while the command is parsed
ResourceOrIdArgument decodes an inline value with the registry's codec
while the command is parsed, so a feature the ConfiguredFeature codec
refuses fails the parse: the command never runs, and an execute store
around it keeps its value. Horizon's parse accepted any compound for
minecraft:worldgen/feature and ran the codec only when the command
executed, which then failed and stored 0.

Where the server has its live features, the command registries now carry
a check that runs the same inline decode placement uses (the live
features' catalog and block bindings, through LiveFeatures::decode_inline),
without placing anything. A value it refuses fails the parse with
argument.resource_or_id.failed_to_parse. Without live features (a client,
or a server without the worldgen bundle) the parse is unchanged. The loot
table and every other registry keep their decode.

The message is the native codec's own (for example "invalid world
generation field: to_place"), not DataFixerUpper's text.

Fixes #12171.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:02:45 +01:00
Horizon agentandClaude Opus 5.5 d2d9dd7ae1 Merge main into #12112
The End flash moved to tick_end_flash here, while main (0b23212f4) switched
the flash and the dragon fight to the dimension type: keep main's
has_ender_dragon_fight check, and gate tick_end_flash on the End skybox as
main did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:58:14 +01:00
Evrim CicekandClaude Opus 5.5 c0a3af67b0 A teleporting shulker throws off whoever rides it where it stood (Entity.unRide)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:57:52 +03:00
Horizon agentandClaude Opus 5.5 acf0cabce9 Merge main into #12110
main (3443abbde) kept NoAI Nautilus out of the needs_water branch; this PR
gives every Nautilus its own handler, which already refills NoAI ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:57:46 +01:00
Horizon agent 72e6ef91db Merge #12108 into #11934 (same effect and attribute code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
#	tools/test_command_exec_parity.py
2026-11-08 01:53:30 +01:00
Horizon agent ab069b576c Merge #11959 into #11934 (same effect and attribute code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-08 01:53:27 +01:00
Horizon agent f521237d6c Merge #11883 into #11934 (same effect and attribute code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/effects.rs
#	crates/horizon-client/src/game/potions.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
#	tools/test_command_exec_parity.py
2026-11-08 01:53:24 +01:00
Nikita Maximilian Grimm 18bbc46240 Merge remote-tracking branch 'origin/main' into item-escape-gravity 2026-11-08 01:53:09 +01:00
Florian ErnstandClaude Opus 5.5 a75a57bd18 Load the world border of the level in place when the world opens
Each level reads its world_border SavedData when it is made
(ServerLevel.getWorldBorder), so a restarted server has the saved border
in place before its first command. #11016 gave each level its own
border. But world open reset the border of the level in place to the
default, and the saved file was read only later, in load_scoreboard (the
first tick's functions, or the first scoreboard use). A first
/worldborder get after a restart read the default, and a first
/worldborder set was put back to the saved size soon after.

World open now reads the in-place level's border with load_border, as
open_level does for the other levels, and load_scoreboard no longer
touches the border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:51:14 +01:00
Nikita Maximilian Grimm aed804a26d Merge remote-tracking branch 'origin/main' into saved-ticks-startup 2026-11-08 01:49:01 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8ce0848a31 Harness: stop a server that never finishes starting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:48:34 +01:00
Nikita Maximilian Grimm 48c080e387 Merge remote-tracking branch 'origin/main' into t3/scheduled-item-tick-pass 2026-11-08 01:48:27 +01:00
Horizon agentandClaude Opus 5.5 02e546660a Merge main into #12115
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:54 +01:00
Horizon agentandClaude Opus 5.5 46e77b05a8 Merge main into #12108
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:51 +01:00
Horizon agentandClaude Opus 5.5 3c73be2517 Merge main into #12076
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:42 +01:00
Evrim CicekandClaude Opus 5.5 917ce67f0d Test: a shulker bullet's hit teleports an open shulker before it returns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:37 +03:00
Evrim CicekandClaude Opus 5.5 b11fe1da84 A shulker's hurt teleports it inside the hurt, as vanilla's does
Shulker.hurtServer calls teleportSomewhere() and hitByShulkerBullet() inside
the hurt. Mob::damage has no world, so the flags it leaves now run right
after the hurt: in the mob's own tick (hurt_teleports, also without AI) and
in the game layer (settle_hurt_teleports: /damage, explosions, projectile
hits, the creature outbox), before a bullet's levitation. teleportSomewhere
also refuses a NoAI shulker, keeps inside the world border and unRides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:36 +03:00
Evrim CicekandClaude Opus 5.5 fdb23b8ac6 Harness: a shulker's low-health teleport inside the hurt (/damage while frozen, poison past tick 281, NoAI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:36 +03:00
Horizon agentandClaude Opus 5.5 ac85e523c7 Merge main into #12012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:51 +01:00
Horizon agentandClaude Opus 5.5 386ab14035 Merge main into #11959
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:40 +01:00
Horizon agentandClaude Opus 5.5 ca82b37eb0 Merge main into #11934
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:15 +01:00
Horizon agentandClaude Opus 5.5 a505caa32a Merge main into #11883
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:50 +01:00
Horizon agentandClaude Opus 5.5 9aa5c9fe4a Merge main into #11879
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:47 +01:00
Horizon agentandClaude Opus 5.5 c7a2daa593 Merge main into #11848
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:43 +01:00
Horizon agentandClaude Opus 5.5 3584268d11 Merge main into #11830
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:26 +01:00
Horizon agentandClaude Opus 5.5 bf1070413f Merge main into #11811
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:18 +01:00
Horizon agentandClaude Opus 5.5 2573d1c76a Merge main into #11783
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:07 +01:00
Horizon agentandClaude Opus 5.5 3a967d8838 Merge main into #11766
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:42:56 +01:00
abit-foggyandCodex GPT-6 000e13fbe6 test(studio): isolate mouse drag hook fixture
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 14:42:49 -10:00
Horizon agentandClaude Opus 5.5 bd967852e5 Merge main into #11729
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:39:31 +01:00
Horizon agentandClaude Opus 5.5 ecdecc8fd2 Merge main into #11728
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:38:55 +01:00
Horizon agentandClaude Opus 5.5 c4c084be94 Merge main into #11708
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:38:51 +01:00
abit-foggyandCodex GPT-6 6c5d89ffac fix(studio): own marker archive entry names
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 14:34:05 -10:00
Nikita Maximilian Grimm 6e72e2de7d Merge branch 'tnt-piston-ground' into orb-piston-ground 2026-11-08 01:29:33 +01:00
Nikita Maximilian Grimm 8b0028bbeb Merge remote-tracking branch 'origin/main' into t3/scheduled-item-tick-pass 2026-11-08 01:28:12 +01:00
abit-foggyandCodex GPT-6 e69f6a5050 feat(studio): add replay top bar and timeline
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 14:25:03 -10:00
itarqos5andClaude Sonnet 5.5 7c34cfa6b2 tools: world_compare.py compares a vanilla and a Horizon world of one seed
Reads the seed, spawn, biomes, structure starts and blocks two saved 26.x worlds
share, with a run-to-run floor from a second world of one game. Run on seed 12345
and a large negative seed: the spawn, every biome and every structure start match,
terrain blocks match to 99.3 to 99.9%, and decoration sits at vanilla's own floor
except trees, a few points under it. perf_bench.py gets --seed to make the worlds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 00:21:56 +00:00
KBroneZandClaude Opus 5.5 c6f52257e2 fix: count 1.20 recipe results in the reason only when there are some
The partial reason of a carried 1.20 mod leaves out the recipe clause
when no result names `item`, and says "1 recipe result names" for one.
docs/modpacks.md says how the game treats each 1.20-shaped file, as an
offscreen run of a carried synthetic 1.20 jar showed: one file at a
time, never the whole start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:12:22 +02:00
Florian ErnstandClaude Opus 5.5 07d0cfc344 Format the mannequin main-hand writer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:11:37 +01:00
Horizon agentandClaude Opus 5.5 949eef6772 Russian launcher strings: fix the JSON and a placeholder
A missing comma after the horizon-server line made ru.json unreadable, so
the whole launcher fell back to English (the i18n tests failed on it).
The {mods} placeholder had been translated, so the count would never be
filled in. Also "подключиться" for two "Couldn't connect" lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:08:05 +01:00
KBroneZandClaude Sonnet 5.5 ecce33154e Launcher: installing a data pack counts what the game reads today
datapacks::READ named four kinds, so a pack of only recipes, loot tables, advancements or
worldgen was refused as changing nothing, and the install note listed them as not read. READ
now lists the kinds the game loads (worldgen by folder, so world presets still count as not
read), the refusal goes through trf(), and docs/datapack-upgrade.md no longer says worldgen is
not applied.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:07:21 +02:00
itarqos5andClaude Sonnet 5.5 d6f88d2483 feat(render): GPU Culling gets an Auto mode that tries it where the CPU limits the frame
GPU Culling pays where the CPU is the slower of CPU and GPU and costs where the
GPU is, and a machine's parts don't say which. Auto watches the frames' median CPU
and GPU times, tries it where the CPU is clearly the slower one, and keeps it only
if the frame's busier side dropped under 97%; a trial that didn't help is undone and
waits 5 minutes, then 20, then for good.

gpuCulling in horizon-graphics.txt is now false|true|auto (older files read as before);
Settings > Performance steps Off/On/Auto; horizon-ctl gpu-culling takes auto. Off stays
the default and no preset sets it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 00:04:36 +00:00
TheMrZZandCodex gpt-6.1-sol 0cdc4a49da Preserve styled container names in the live block model
Carry admitted Component tags through name state, saves and drops; let modeled name removal override stale source names.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 01:02:29 +01:00
abit-foggyandCodex GPT-6 68459f0442 feat(studio): persist timeline markers
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-07 14:02:00 -10:00
Nikita Maximilian Grimm 2877c7fbfa Merge remote-tracking branch 'origin/main' into saved-ticks-startup 2026-11-08 01:01:52 +01:00
KBroneZandClaude Sonnet 5.5 5be2aba2ae style(port-pack): rustfmt the shaderpacks test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 02:01:09 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 6b371cdc48 HDR float frame: the frame follows HDR and the pack picked at once
Under HORIZON_HDR_FRAME=float, when HDR, Smooth Gradients or the pack picked call for another
frame than the renderer's, the renderer is made again on the same device before the next frame
draws, and the world is meshed again, as after a lost device. Without the switch nothing changes:
the frame stays until the next start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:00:38 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 1b63e0abc2 Startup waits for the forced chunks before the first tick, so saved ticks there fire on time
MinecraftServer.prepareLevels waits for the chunks the restored tickets hold
(ChunkLoadCounter) before the first tick, and the first tick unpacks their
saved block and fluid ticks at the game time they were saved at. Horizon's
dedicated and integrated servers started ticking while the forced chunks were
still streaming in, and the saved ticks were unpacked from the tick the chunk
arrived, one tick or more late.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:58:28 +01:00
Nikita Maximilian Grimm 9ab82c59cd Merge main into hdr-tone-curve: #11987 is on main now 2026-11-08 00:56:51 +01:00
Nemotron e0cbbdb50a Fix piston push not zeroing entity velocity in lazy chunks (#12022)
move_pushed applied the resolved movement to position only, leaving the
entity's velocity untouched. In a ticking chunk the entity's own tick
masks this (PrimedTnt::tick_among restitutes), but in a lazy chunk the
velocity survives: a primed TNT pushed by a piston into a wall, ceiling
or slime keeps 0.4 / -0.8 / -0.8 instead of vanilla's 0 / 0 / 0.64.

Add restitute_piston after apply_push: compare the post-piston-limit
movement (limited) with the post-collision result (resolved) and, as
Entity.move(MoverType.PISTON, …) does, zero blocked horizontal axes,
zero or slime-bounce the vertical axis, and update on_ground. Covers
Player, Guest and every Entity kind including PrimedTnt.

Add unit test a_pushed_tnt_loses_velocity_into_a_wall (needs prepared
resources) asserting the TNT's vx is zeroed by a wall.

Co-Authored-By: Nemotron <[email protected]>
2026-11-08 02:56:20 +03:00
KBroneZandClaude Sonnet 5.5 56ec50a875 fix(port-pack): an empty shaderpacks folder is not a shader pack to convert
The CurseForge app, and mods such as Sodium, create `shaderpacks/` in an instance
even when the player never installed a shader pack. Import counted the folder
itself as one "shader pack, conversion pending" file and listed it under "not yet",
so a client-only instance such as one with Sodium, Lithium, Jade, malilib and
Litematica reported 4 "not yet" rows for 3 mods. A folder instance now reports
shaderpacks/ only when it holds something, and a zip only when it has a file
under its shaderpacks/ (a bare directory entry no longer counts).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 01:54:47 +02:00
Florian ErnstandClaude Opus 5.5 066b6e9cef Save a mannequin's own fields and none of a Mob's
Mannequin extends Avatar extends LivingEntity; it is not a Mob.
Mannequin.addAdditionalSaveData always writes profile, hidden_layers,
main_hand and pose after LivingEntity's fields, and none of
Mob.addAdditionalSaveData's. Horizon left out the four unless they were
loaded, and wrote PersistenceRequired, CanPickUpLoot, LeftHanded and NoAI.

A mannequin now reads main_hand and hidden_layers into the main-arm and
hidden-layer state it already had (a list with an entry that is not a part
hides nothing, as the codec's failed read), and writes all four from its
live state: the empty profile when it has none, the hidden layers in
PlayerModelPart order, its arm and its pose. It neither reads nor saves
Mob.readAdditionalSaveData's flags, and its save drops the Mob-owned keys
over a stale loaded compound. Every other kind is unchanged.

Fixes #12135.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:53:14 +01:00
KBroneZandClaude Opus 5.5 172d7c51e6 fix: keep the 1.20 partial reason on one line of words
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:51:33 +02:00
Nikita Maximilian Grimm 5cf4a1dec8 Merge remote-tracking branch 'origin/main' into t3/scheduled-item-tick-pass 2026-11-08 00:49:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4bf9b604f7 Keep the contact drop range half-open
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:49:15 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a9612bdabc Apply an item's gravity before pushing it out of a block
ItemEntity.tick applies gravity (or the fluid movement) first and then
moveTowardsClosestSpace, whose 0.75 scales the already-fallen vertical
speed. The push ran before tick_move_before_block_effects, so gravity
came after it. Pass the escape speed into the move and apply it right
after gravity; the native block-contact pipeline is unchanged.

Fixes #12018

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:25:22 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a20d3296b4 A sideways piston push no longer leaves an experience orb marked on ground
The orb kept its own vertical-only on-ground rule for a piston push. It
now takes the server's end-of-move rule that items, primed TNT and armor
stands use: on ground only when the push's downward part was blocked.

Fixes #12100

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:23:06 +01:00
RINNECODERandClaude Opus 5.5 eadbb41095 Shader packs: run a workGroups over 65535 per dimension
wgpu caps a dispatch at 65535 work groups per dimension and panics past it;
GLSL packs may ask for more (131072). A larger workGroups now runs as the
same number of groups in a shape within the limit, and main works out each
group's place in the declared dispatch, so gl_WorkGroupID,
gl_GlobalInvocationID and gl_NumWorkGroups read what they would there. The
shape uses every group exactly, so no group leaves before a barrier. A
count no shape holds skips the program with a note; an oversized
workGroupsRender dispatch is not run instead of panicking.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:22:07 -06:00
RINNECODERandClaude Opus 5.5 bc59734a29 Shader packs: copy a struct or array holding atomics as its declared type
naga's GLSL frontend turns a uint/int that an atomic*() call touches into
an atomic type after parsing. A load or store of a whole struct or array
holding such a member then had the upgraded type: `Cell c = buf.cells[i];`
and `uint copy[4] = shared_counts;` failed validation. A whole struct or
fixed-size array of integers in a buffer or shared memory is now loaded
and stored member by member, so each copy keeps its declared type.

Vendored-naga patch: third_party/naga/src/front/glsl/context.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:58 -06:00
RINNECODERandClaude Opus 5.5 626c60740d Shader packs: make an image in its layout qualifier's format
An image declared RGB10_A2, RGB10_A2UI or a 16-bit SNORM format was made
as an RGBA8, RGBA8UI or 8-bit SNORM texture, so a shader binding it as
layout(rgb10_a2) etc. was refused ("is rgb10_a2 but its texture is
Rgba8Unorm"). Each now gets the wgpu format of its GLSL qualifier; one the
GPU can't store into is left out with a note, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:58 -06:00
RINNECODERandClaude Opus 5.5 0bd5a4f201 Shader packs: an exact argument beats a conversion in GLSL overloads
naga's GLSL frontend never compared an exactly matching argument with the
previous best overload's conversion there, so greaterThanEqual(ivec3, uvec3)
found the uvec3 overload no better than the vec3 one and reported
"Ambiguous best function". GLSL 4.60 §6.1 ranks an exact match above any
implicit conversion: the uvec3 overload wins. Zephyr Starlight's
shadowcomp1-4 failed on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:20:49 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 60fff3987f Keep the drops of entities' block contacts on the next tick
tick_blocks applies the entities' block contacts (a boat breaking a lily
pad) before the scheduled ticks; vanilla runs them inside each entity's
own tick, so their drops still wait for the next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:19:02 +01:00
KBroneZandClaude Opus 5.5 da4d26b00d feat: carry 1.20 jars' data under 1.21's folder names
A content mod jar whose data files are mostly in the folders 1.21
renamed (recipes/, loot_tables/, structures/, advancements/,
predicates/, item_modifiers/, functions/ and tags/blocks, items,
entity_types, fluids, game_events, functions) was refused by `carry`.
Now the carry moves each such file to the singular name 26.3 reads.
A file the jar also ships under the new name keeps that copy, and a
1.21 jar's stale plural folder is left as it was.

The JSON stays 1.20's: a recipe whose result names its item as `item`
(before 1.20.5), which 26.3 skips, is kept as it is instead of counted
as upgraded, and NBT in loot functions, ingredients and predicates is
not upgraded yet. The report therefore marks such a mod partial
whatever its harness says, and says why. `check` still refuses 1.20
jars until the JSON steps of #10262 land.

Part of #10262.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:14:06 +02:00
KBroneZandClaude Sonnet 5.5 342315073f Launcher: the data pack support text says what the game reads today
The browser's data pack note still said recipes, loot tables, advancements, other tags and
worldgen were not read. The game reads them now. The note goes through tr() and is in all
86 language tables (translated in 14, English elsewhere).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 01:13:03 +02:00
martipopsandClaude Opus 5.5 de0132b638 Controllers: the craft prompt says Quick Craft, and its setting's description is shorter
Two words, like the guide's other rows, and the same name as Quick Craft on
Servers. The exact rule (single player, LAN worlds from the server list) stays
in docs/controllers.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:12:25 -05:00
Nikita Maximilian Grimm 30f1fc145d Merge remote-tracking branch 'origin/main' into t3/scheduled-item-tick-pass 2026-11-08 00:10:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f509e284bf Tick an item a scheduled block tick drops in that tick's entity pass
ServerLevel.tick runs the block ticks and block events before
entityTickList.forEach, so a dropper's item ticks on the tick it is
dispensed. The item cutoff was taken right after the tick functions;
take it just before the entity pass instead. Primed TNT and falling
blocks keep their own packet cutoff (#10063).

Fixes #12017

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:10:05 +01:00
itarqos5andClaude Sonnet 5.5 3d5216e717 perf(render): pass terrain's interpolants in 5 locations, not 12
Terrain's vertex stage exported 20 scalars in 12 interpolant locations. A GPU's
vertex stage pays per location (AMD gives each a parameter slot): on an RX 580 the
12 cost 3.2 ns per quad and the same values in 5 cost 1.6. TerrainOut stays the type
the shader code uses; TerrainWire crosses the stage boundary, each component
interpolated on its own and the integers flat, so a pixel gets the same values.

RX 580, Vulkan, 1080p, render distance 16: village 523.7 -> 590.8 FPS (+12.8%),
forest 267.6 -> 323.7 (+21.0%).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-07 23:09:46 +00:00
hunix 14ba691e53 Components: keep a translatable fallback literal instead of looking it up as a key
The component parser translated a missing key's fallback as if it were another key, so a fallback that named a
real key showed that key's translation. It now builds the text through Text::translate_with_fallback, as
TranslatableContents.decompose does (Language.getOrDefault(key, fallback)), which also keeps the fallback in the
component's JSON. Fixes #12042.
2026-11-07 13:02:44 -10:00
hunix 877cb37978 Web panel: accept LAN browsers when bound to 0.0.0.0 or ::
Bound to every interface, the panel's Host check accepts any IP address with the bind's port (and localhost),
which a rebound DNS name can never be, and the login link names this computer's network address instead of
0.0.0.0. Fixes #12039.
2026-11-07 13:02:44 -10:00
bulkandClaude Opus 5.5 12273a4e8e Natural spawning: find the players near a spawn start by square
Every spawn start measured its distance to every player to keep those
within reach of its attempts. With more than 8 players, the level keeps them
by the 256-block square they stand in, built once a pass first asks, and a
start reads the 3×3 squares around it: every player within reach is in
them, and the nearest one is the same in any order. The spawn draws are
untouched.

(the player-square part of commit e3b70aace7; its region spawning is left
out)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:56:49 +00:00
Kacpherek 34194ab7ec test: give the remote mining fixture's remote_break_restricted the position it now takes 2026-11-07 23:47:06 +01:00
Florian ErnstandClaude Opus 5.5 7803f1351e Cite AttributeMap.pack for the sulfur cube attribute save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:46:38 +01:00
Florian ErnstandClaude Opus 5.5 c9b07b1ffb Pick /test pos's block with a connected source player's view
TestCommand.showPos takes the source player (getPlayerOrException) and
that player's own pick: its eyes and view, not the execute position. So
`execute as <connected player> run test pos` from another player answers
with the block that player looks at, and the issuer is told. Horizon
only accepted the player in place, so the command did nothing.

For a connected player as the source, /test pos now picks the block with
that player in place (for_player) and only the pick runs there; the test
is looked up in the command's level and the issuer told afterwards. Every
other source keeps its current rule, and the highlight packet is
unchanged.

Fixes #12102.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:45:41 +01:00
Kacpherek ec77214342 Send a bounce in the tick it happens: move the update clock to its next interval (ServerEntity.sendChanges) 2026-11-07 23:42:53 +01:00
PetroandOpenAI GPT-6 Luna bec6ac6448 Fix legacy Intel Metal startup and terrain rendering
Use compatible compute shaders and direct terrain draws on older Intel Metal.

Co-Authored-By: OpenAI GPT-6 Luna <[email protected]>
2026-11-07 23:42:25 +01:00
Kacpherek a2b3f669ef Swing at a wandering trader only for a named name tag, not a lead or an unnamed tag (NameTagItem, Entity.interact) 2026-11-07 23:42:05 +01:00
Kacpherek 7bfecd3fd1 Time the End flash by the End's own clock (ClientLevel.tick: getDefaultClockTime) 2026-11-07 23:41:38 +01:00
Kacpherek 43ef30f98c Draw wings and move the cape only for a chest-slot equippable (HumanoidArmorLayer.shouldRender) 2026-11-07 23:41:25 +01:00
Kacpherek ce4f0d7074 Let shears in hand still take a camel's back seat or open a ridden mount's inventory (Entity.interact falls through to mobInteract) 2026-11-07 23:41:12 +01:00
Kacpherek 4ba236928d Run no heartbeat sound or random draw for the warden on the server thread (Warden.tick's client branch only) 2026-11-07 23:41:04 +01:00
Kacpherek GIT_COMMITTER_NAME=Kacpherek [email protected] [email protected] 34c5bb84e6 Keep a silent breeze's whirl and idle sounds quiet (EntityBoundSoundInstance.canPlaySound) 2026-11-07 23:40:45 +01:00
Kacpherek GIT_COMMITTER_NAME=Kacpherek [email protected] [email protected] e809bed768 Play the warden's heartbeat once in single player: the server thread's copy is a local sound too 2026-11-07 23:40:45 +01:00
Kacpherek 031037cee5 Send stepped move paths only for living entities, as vanilla's SteppedInterpolationTracker does 2026-11-07 23:39:00 +01:00
Kacpherek 70342ed68b Show your own crit particles on critical hits against other players on servers (Player.attackVisualEffects, LocalPlayer.crit) 2026-11-07 23:30:01 +01:00
Kacpherek 98d7553124 Unlock used_ender_eye advancements when throwing an eye of ender (UsedEnderEyeTrigger.trigger)
EnderEyeItem.use now fires the minecraft:used_ender_eye criterion with the squared horizontal distance to the located structure, matched against the distance bounds squared as MinMaxBounds.Doubles.matchesSqr does.
2026-11-07 23:29:23 +01:00
Kacpherek ef6fc399f5 List the blocks an item can break or be placed on in its tooltip (AdventureModePredicate.addToTooltip) 2026-11-07 23:29:04 +01:00
Kacpherek 94aeeac20d Flash the End sky on servers and pan its rumble toward the flash (ClientLevel.tick, DirectionalSoundInstance)
The End flash state now ticks on every client, so servers, LAN guests and single player show the sky flash and its light. Its weather.end_flash sound plays 30 ticks later ten blocks from the camera toward the flash, follows the camera, and is skipped under the credits.
2026-11-07 23:28:35 +01:00
Kacpherek 7f51885a85 Show mobs bouncing on slime blocks and beds to other players without cutting the bounce short (SteppedInterpolationTracker) 2026-11-07 23:28:17 +01:00
Kacpherek 5ecd155b76 Stop cracking blocks a can_break tool may not break in adventure mode on servers (Player.blockActionRestricted) 2026-11-07 23:27:33 +01:00
Kacpherek 5fe374444f Keep riders in the portal when they get off a vehicle standing in one (LivingEntity.dismountVehicle)
Getting off a boat, minecart or mount whose block is in #minecraft:portals now takes the free-position branch straight up from the rider, as for a removed vehicle, instead of the vehicle's side dismount spot.
2026-11-07 23:27:14 +01:00
Kacpherek c3f7e94775 Let foxes stroll into villages at night (Fox.FoxStrollThroughVillageGoal) 2026-11-07 23:26:35 +01:00
Kacpherek ff50de2553 Shear saddles off horses, striders, camels and nautili with a sound (Mob.attemptToShearEquipment)
Shears now take the saddle off any #can_equip_saddle mob, not only pigs, and play the saddle's shearing sound item.saddle.unequip unless the mob is silent. A mount wearing horse or nautilus armor is left alone, since vanilla shears that body armor first.
2026-11-07 23:26:20 +01:00
Kacpherek d4ea299fdd Let adventure-mode can_break and can_place_on predicates with nbt or components match their blocks (BlockPredicate.matches(BlockInWorld)) 2026-11-07 23:25:54 +01:00
Kacpherek 5383881562 Mark worlds with experimental features as Experimental in the world list (LevelSummary.createInfo) 2026-11-07 23:25:20 +01:00
Kacpherek 1c402c47a7 Anger piglins that see a player break a chest boat (ContainerEntity.chestVehicleDestroyed)
A survival player's hit that breaks a chest boat with entity_drops on angers the piglins that see it, as AbstractChestBoat.destroy does through chestVehicleDestroyed.
2026-11-07 23:25:04 +01:00
Kacpherek 6e5f7e88f7 Hold a lead at the right point while gliding, riptiding or swimming in third person (Player.getRopeHoldPosition)
The lead now hangs from the body's yaw, not the head's, follows the eased position, and uses vanilla's gliding/riptide and swimming hold points.
2026-11-07 23:24:48 +01:00
Kacpherek 75dd0dde7d Save and load a mob's hurt immunity as invulnerable_time (Entity.load / saveWithoutId)
A mob writes invulnerable_time while it has hurt immunity left and reads it back with a default of 0, so /data get shows it, /data merge sets it, and the immunity survives a save and reload.
2026-11-07 23:24:42 +01:00
Kacpherek 8c4e3bb507 Pour a water bottle onto dirt to make mud at once on servers instead of drinking it (PotionItem.useOn) 2026-11-07 23:24:38 +01:00
Kacpherek dabae908cd Keep hidden server list entries, so Direct Connection servers remember pack and Code of Conduct answers (ServerList.save, ServerList.add) 2026-11-07 23:24:26 +01:00
Kacpherek 211b53f598 Let a ravager's roar knock armor stands away when mob griefing is on (Ravager.roar) 2026-11-07 23:24:19 +01:00
Kacpherek 2d94afaa61 Keep counting time since last rest on the death screen (ServerPlayer.doTick)
time_since_rest now counts every tick the player is not sleeping, dead or alive, as ServerPlayer.doTick does; time_since_death still counts only while alive.
2026-11-07 23:23:39 +01:00
Kacpherek 14a5193b41 Use up a knowledge book and swing the arm at once on servers (KnowledgeBookItem.use) 2026-11-07 23:23:35 +01:00
Kacpherek 00d050bb8f Show each worn item's own camera overlay (Hud.extractCameraOverlays)
Items a server or datapack gives a camera_overlay now draw it in first person when worn in their equippable slot, and a carved pumpkin whose equippable was removed no longer blurs the screen.
2026-11-07 23:23:29 +01:00
Kacpherek 7225a84b10 Count thrown snowballs, eggs, pearls, potions and xp bottles as used (throwable Item.use)
Throwing a snowball, egg, ender pearl, splash or lingering potion or bottle o' enchanting awards the used statistic, as SnowballItem, EggItem, EnderpearlItem, ThrowablePotionItem and ExperienceBottleItem.use do.
2026-11-07 23:23:21 +01:00
Kacpherek 40afd73cd8 Draw wings and hide the cape by the chest stack's own equippable (WingsLayer.submit, CapeLayer.hasLayer)
A chestplate a server gives elytra wings now shows them and hides the cape, and an elytra whose equippable was changed or removed no longer shows elytra wings.
2026-11-07 23:23:03 +01:00
Kacpherek c54c8c0f42 Let a beached nautilus dry out instead of drowning, and spare NoAI ones (Nautilus.handleAirSupply) 2026-11-07 23:23:02 +01:00
Kacpherek 939d492535 Let sculk hear item frames break as a block change too (ItemFrame.dropItem)
A frame broken by a player, an explosion or losing its support posts block_change after its drops, as ItemFrame.dropItem(level, causedBy) does, on top of the entity_die it already posted.
2026-11-07 23:22:57 +01:00
Kacpherek 41934f3d93 Let sculk hear items knocked out of item frames (ItemFrame.hurtServer)
Punching the item out of a frame posts block_change with the attacker as source after the item drops.
2026-11-07 23:22:45 +01:00
Kacpherek 85a6a8b9dc Let sculk hear items put into or turned in item frames (ItemFrame.interact)
Putting an item in a frame or rotating it posts block_change with the player as source, after the item or rotation changes.
2026-11-07 23:22:36 +01:00
Kacpherek 4ca3d55e78 Draw worn armour from the stack's own equippable asset (HumanoidArmorLayer.renderArmorPiece)
Armour a server or a command gives a different or removed equippable now renders with that asset, or not at all, and only in its own slot, on players, mobs and armour stands. The head layer draws a helmet item when its equippable has no asset.
2026-11-07 23:22:27 +01:00
Kacpherek b97f0f61d7 Let sculk hear endermen pick up and put down blocks (EnderMan block goals)
EndermanTakeBlockGoal.tick posts block_destroy and EndermanLeaveBlockGoal.tick posts block_place, each with the enderman and the block state as context.
2026-11-07 23:22:05 +01:00
Kacpherek 0c40da1a82 Let an opening shulker lift and push you on servers too (Shulker.onPeekAmountChange on the client) 2026-11-07 23:22:04 +01:00
Kacpherek e01adc67e3 Let Thorns hurt a player who sweeps its wearer in PvP (Player.doSweepAttack's doPostAttackEffects) 2026-11-07 23:21:48 +01:00
Kacpherek 32137ae44f Answer pandas' clicks on servers as vanilla does: scared ones pass, ones on their back swing, adults leave bamboo to the off hand (Panda.mobInteract) 2026-11-07 23:21:39 +01:00
Kacpherek 9503a3cc7d Let sculk hear copper golems open and close chests (ContainerOpenersCounter)
The first golem to open a chest half posts container_open with the golem as source, the last to close it posts container_close (incrementOpeners / decrementOpeners), and the openers recheck posts a sourceless container_close when the golems have walked off (recheckOpeners).
2026-11-07 23:21:26 +01:00
Kacpherek 7c4a6d1ea6 Use the held item on a pig or strider someone already rides on servers (Pig/Strider.mobInteract) 2026-11-07 23:21:14 +01:00
Kacpherek 3c4d936707 Say "formerly known as" when a renamed player joins a server (PlayerList.placeNewPlayer) 2026-11-07 23:21:11 +01:00
Kacpherek 44f5a37e64 Open a wandering trader's trades on servers without swinging the arm (WanderingTrader.mobInteract) 2026-11-07 23:21:01 +01:00
Kacpherek 5ffd5c6515 Use the held item on a sleeping villager on servers instead of swallowing the click (Villager.mobInteract) 2026-11-07 23:20:45 +01:00
Kacpherek 8cd7616054 Let breezes whirl and idle on servers too (Breeze.tick and Breeze.playAmbientSound on the client) 2026-11-07 23:20:44 +01:00
Kacpherek ea99e6572c Let sculk hear glass bottles filling and count their uses (BottleItem.use)
Filling a bottle from water now plays the fill sound at the player and posts fluid_pickup at the water block; both the water and the dragon's breath branch award the used statistic (BottleItem.turnBottleIntoItem), and collecting dragon's breath fires player_interacted_with_entity on the cloud.
2026-11-07 23:20:32 +01:00
Kacpherek c5dbb620c2 Let sneak-clicks and clicks on occupied cushions reach the held item on servers, without an arm swing (Cushion.interact) 2026-11-07 23:20:24 +01:00
Kacpherek 642ac0d489 Play the warden's heartbeat on servers too (Warden.tick's client branch) 2026-11-07 23:19:51 +01:00
Kacpherek 92bedb26d0 Show team colours and prefixes in /list on LAN worlds (ListPlayersCommand.listPlayers uses Player.getDisplayName) 2026-11-07 23:19:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 721d4cfa44 Resource packs: a pack's core/sky and core/stars shaders
A resource pack's core/sky now draws the overworld's sky disc and its
core/stars the stars, as vanilla's SkyRenderer does with the SKY and
STARS pipelines: the disc of buildSkyDisc(16) with the sky colour in
ColorModulator and the sky's fog, the stars with their brightness, both
turned with the camera only. Without such a pack the sky draws natively.

Packs use this to tint the night sky or add a haze to the horizon. With
Visual Effects+'s sky shaders alone, Horizon's sky now reads within a few
units of the Java client's at noon, sunset and night.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:47:59 -03:00
martipopsandClaude Opus 5.5 78b21e4afb Graphics moves onto the settings shell
Part of #11487. The graphics screen now describes its pages and rows
(`GraphicsScreen`), and the shell draws them, takes the clicks, the wheel,
the stick, the keys and the pad's focus, as it does for Extras. What each
setting does, its saved value, its cost and its description stay in
graphics_ui as they were; Undo puts back the same settings.

What changes beyond what the shell gives every page: the HDR shortcut row
shows its key on a key button, as Extras' Sort key does, instead of between
arrows. The screen's own keyboard, scrolling and drawing code goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:47:11 -05:00
martipopsandClaude Opus 5.5 26496db7c3 Merge upstream/main into controller/direct-craft
Main moved key_pressed to game/play_keys.rs and inventory_press to
game/inventory_input.rs; the craft's stop on a key or mouse press moves
with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:46:48 -05:00
martipopsandClaude Opus 5.5 d5f931ca64 Controllers: LT + A on a recipe crafts it, held to keep crafting
LT + A on a crafting book recipe crafts it into the inventory: a tap makes
one, each further LT + A while one is in flight makes one more, and held past
the repeat delay it keeps going until a button comes up, the items run out
or the inventory is full. One request at a time, each waiting for the
server's answer; a result moves only with room for all of it as the client
last saw the inventory. A result that isn't the recipe's stops the craft with
nothing moved, and two seconds with no answer stops it with nothing more
sent. A refused LT + A (a carried stack, another recipe still finishing, not
allowed here) is plain A.

It's free in single player and on LAN worlds joined from the server list's
LAN entries; everywhere else, joins by address included, it waits for Quick
Craft on Servers. On any server but single player's own, placements keep
250 ms apart, under Paper's default limit on recipe placements (5 a second
over 4 seconds); on servers that need the option, every request also keeps
a tick apart.

On the crafting book LT changes the category as it comes up unless A was
pressed meanwhile; RT, and both triggers on the furnace books, change it as
they go down, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:41:47 -05:00
martipopsandClaude Opus 5.5 a08827d6d5 Settings shell: Undo, a row's cost and why it is greyed
Part of #11487. What the graphics screen has that the shell couldn't say
yet, for any screen that wants it:

- Undo over Done once a row changed something, putting back what the
  screen says it changes (`Screen::undoes`, `Screen::undo`), from the
  settings as the screen found them. Tab and the arrows reach it.
- A row's cost, a square and the cost in its colour after its name in the
  strip.
- Why a greyed row can't be used, in the strip, muted, instead of what it
  does.
- `Shell::show` opens a screen at one of its pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:37:34 -05:00
Florian ErnstandClaude Opus 5.5 c0806b9ee3 Keep a failed wildcard set's edit in command storage
StorageDataAccessor.getData returns the stored compound itself, and
DataCommands.manipulateData edits it in place. A wildcard set on a
non-empty byte, int or long array clears the array before it rejects a
string, so the command fails with nothing counted and the storage keeps
the cleared array. Horizon edited a copy and dropped it on the failure.

On that failure, a /data modify on storage now puts the edited compound
back with CommandStorage::set_unsaved: only an id that is stored is
replaced, and the storage is not marked for saving, as vanilla calls no
setData. The error, the result, the success path and the entity and
block copies are unchanged.

Fixes #12057.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:08:42 +01:00
KBroneZandClaude Opus 5.5 70bc17720c fix(launcher): keep a Prism root's instances folder from reading as CurseForge's
Where folder names ignore case (Windows, macOS), a Prism or MultiMC root's
`instances` folder is also `Instances`, so `in_folder` took it for the
CurseForge app's modding folder and found nothing. It now reads that folder
as CurseForge's only when none of its instances has an `mmc-pack.json`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:03:37 +02:00
bulkandClaude Opus 5.5 25ea320338 Streaming: rank decoration by rank alone
Decoration scheduling measured, sorted and ranked every proto chunk each update.
Ranks are unique, so only chunks without one need their distance; the rest
pop from a heap until the workers are busy. A server with 200 players spread
over the map held ~57,000 protos and spent a fifth of its tick here.

The heap pops in the order the full sort gave; unranked chunks at the same
distance now go by position instead of where the unstable sort left them.

(cherry picked from commit d20e7bf74e)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:57:34 +00:00
KBroneZandClaude Opus 5.5 6fa7f8896a feat(launcher): find instances in the CurseForge app's Instances folder
`instance others --folder` and `copy-from --folder` now accept a folder whose
children are CurseForge instances, the `Instances` folder the app shows the
player, as well as the modding folder above it. Before, that folder found
nothing.

When nothing is found, the message says where it looked (the folder named, or
every default folder the scan reads) and which folders it expects. The
CurseForge folder is joined one component at a time, so its path no longer
shows mixed separators on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 22:57:04 +02:00
bulkandClaude Opus 5.5 94ed2f4cc8 Beehives: ask once per hive turn whether bees stay in
bees_stay_in_hive built the mob world (every mob and player) for each occupant
whose time was up. The night and rain it reads hold for the hive's turn.

(cherry picked from commit 3430283af0)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:48:44 +00:00
bulkandClaude Opus 5.5 517f54d076 Streaming: know the extra cover is current instead of comparing it
Every in-range check compared the cover's copy of the extra list (about 200
entries on a spread server) with the live one, for each waiting chunk each
tick. The cover is refreshed at the start of update; update now says so and
the checks trust it, with a debug assertion that they still match.

(cherry picked from commit f61e6ed98d)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:48:44 +00:00
bulkandClaude Opus 5.5 9626c7446e Creaking hearts: build the mob world only for the heart's update
A heart built the mob world (every player, the teams, the weather) each tick,
but reads it only in the update about once a second. server_tick is split at
that point; the game builds the world for the update only. The heart's
creaking, asked for a few times a tick, is found through the mob index
instead of a scan.

(cherry picked from commit a1cf6491dd, with the mob index
in place of its own slot map)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:48:44 +00:00
bulkandClaude Opus 5.5 da2b437f49 Spawners: count the delay down without building a level
BaseSpawner.serverTick only decrements a running delay. The level (every
player, the mob world) was built for each spawner near a player every tick
first.

(cherry picked from commit 1f06b720c9)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:48:44 +00:00
bulkandClaude Opus 5.5 6e0f482e83 Structure starts: keep four times as many chunks cached
Players spread far apart read more start chunks than the cache held, so
outposts' and trial chambers' jigsaws were built again on the server thread
whenever natural spawning asked about them.

(cherry picked from commit da2c8d2d48)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:48:44 +00:00
Florian ErnstandClaude Opus 5.5 38a1babc5a Use the archetype data's float for the knockback modifier amount
The archetype table keeps knockback resistance as a decimal double for
the physics code. The data's modifier amount is the float widened
(0.4000000059604645 for slow_bouncy), so /attribute read 0.4 and 0.7
where vanilla reads 0.4000000059604645 and 0.699999988079071.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:26:16 +01:00
Florian ErnstandClaude Opus 5.5 3e5e8c4841 Give a sulfur cube its body archetype attribute modifiers
SulfurCube.collectEquipmentChanges runs in LivingEntity.tick
(detectEquipmentUpdates) when the body item changed. It removes the
previous body's archetype modifiers and adds the new body's as transient
modifiers: knockback_resistance, explosion_knockback_resistance and
bounciness (add_value), friction_modifier and air_drag_modifier
(add_multiplied_total). /attribute read the defaults for these five on a
cube with a body.

A sulfur cube now has its own attribute map from its type's defaults.
The five attributes go to that map, and the tick adopts the body's
modifiers there with the existing AttributeInstance operations. A
transient modifier shadows a permanent one with the same id, and the
removal takes both, as in vanilla. The archetype table also gets its
explosion knockback resistance, from the 26.3 data.

The save writes the base and the permanent modifiers of these instances,
not the transient ones. A command change of the knockback base or of a
permanent modifier also goes to the store that the bodiless physics
reads, so that effect stays as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:18:07 +01:00
Florian ErnstandClaude Opus 5.5 ed1ce7495e Merge origin/main into fix/chorus-projectile-drops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:59:09 +01:00
MSedgeMC d1f8cccb7c A mob stops chasing a target outside its home
`MeleeAttackGoal.canContinueToUse`'s follow branch returns
`isWithinHome(target.blockPosition()) && !(target is a spectator or
creative player)`. Horizon's `MeleeAttack::can_continue` only refused a
creative player: a mob with a home kept chasing past its radius, and a
player who became a spectator was still chased.
2026-11-07 19:46:00 +00:00
Florian ERNSTandCodex 60a2c3f164 Preserve loaded NoAI Enderman absolute anger timestamps
Co-Authored-By: Codex <[email protected]>
2026-11-07 20:36:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4fa237a12e HDR layered frame: a tone curve eases the light into the peak
After the post effects and before the camera overlays, ITU-R BT.2390's EETF in PQ rolls the
light off into the peak, on its brightest channel so it keeps its hue. It takes over the HDR
lighting's easing and the light sources' cap at the peak, which the resolve pass no longer
applies. The knee stays where the lighting's was, 60% of the peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:14:01 +01:00
masa-masa-0314andZCode aaa674f54b Realms: the screens follow vanilla's layout, state and input
The parity work after the per-screen PRs (#9714, #9836, #9909, #9926,
#9968, #9982, #10032, #10391, #10448, #11640), as verified in the local
playtest builds:

- Main list: RealmsMainScreen's header and footer geometry, row content
  and status light, scrolling, notices, selection narration, and the
  60 s/10 s/300 s data-fetcher periods.
- Configuration screen: the four tabs (MenuTabBar), GridLayout columns,
  slot options, region preference, invite codes and their edit screen,
  the players tab (invites, operators, removal), the subscription tab
  and the tab keyboard.
- World screens: RealmsResetWorldScreen's tiles, the template list, the
  upload selection and upload screen, and the PDP rows and keyboard.
- Backups: RealmsBackupScreen/RealmsBackupInfoScreen on the current wire
  (lastModifiedDate, metadata as strings, a local changeList), restore
  as RestoreTask (retries, abort), and the complete Download Latest flow:
  DownloadTask, RealmsDownloadLatestWorldScreen's size check, bar, speed
  and cancel, and FileDownload's tar.gz unpack (name cleanup, world/
  entries only, level.dat renamed, resource pack SHA-1 checked).
- Settings: RealmsSettingsTab's GridLayout/ScrollableLayout, the
  distance sliders, the name validation, and tooltips drawn after every
  widget.
- Service: the ops/invites endpoints, the invite answer's players, the
  backup age on the wall clock, null gameMode, and news persistence.
- Integrated server: the owner and the memory connection's login use the
  account profile (IntegratedServer.setSingleplayerProfile,
  ServerboundHelloPacket), so a world without singleplayer_uuid (a Realms
  download) loads the owner's players/data file; saveAllChunks'
  saveDataTag(profile.id()) is mirrored by save_owner.

Tests: 2166 lib tests pass (65 realms), scoped clippy clean.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 03:00:28 +08:00
Florian ErnstandClaude Opus 5.5 e2367a4b6a Skip the full Mob tick tests without the vanilla data
CI has no prepared resources, so the assert on the test world failed
there. Do what the other test-world tests do: check nothing when the
data is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:49:30 +01:00
Florian ERNSTandCodex 408e2606d8 Preserve weaker Mob effects through countdown and promotion
Keep active tuples authoritative and carry only fallback EffectInstance subtrees through Mob load/save and the audited mutation boundaries. Reuse the existing pure duration operation after server work.

Co-Authored-By: Codex <[email protected]>
2026-11-07 18:59:12 +01:00
SenjinandClaude Opus 5.5 fb3f1bce90 Shader packs: LabPBR maps for entities, items and the hand
The shared atlas's textures (items, entities, block entities) now bring
their _n and _s maps to shader packs as paged maps: 16x16 pages of the
atlas, only those holding a mapped texture stored, found through a page
table at the map's start. The programs drawing that geometry read
normals and specular through hz_pagedTexture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:39:19 +01:00
Florian ErnstandClaude Opus 5.5 833bb5cab4 Merge origin/main into fix/h155-fork-function-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:23:43 +01:00
IwantHappiness 5d9d9fb3ab Merge branch 'main' into translate-ru-lang 2026-11-07 20:18:57 +03:00
Florian ErnstandClaude Opus 5.5 65e2abae80 Count Absorption in a mob's max_absorption and clamp its amount to it
Absorption adds 4 per level to max_absorption (an ADD_VALUE modifier),
and LivingEntity clamps the absorption amount to getMaxAbsorption: when
the effect starts (setAbsorptionAmount) and when it goes
(onAttributeUpdated). A mob's max_absorption lived only in the command's
per-target attribute map, without the effect's modifier; /effect clear
left the amount, and expiry subtracted 4 per level, so a mob with a
raised base lost absorption it keeps in vanilla.

The mob's max_absorption now uses the attribute map it already has for
armor; its effective value counts the effect in the ADD_VALUE phase. The
add clamps the amount to it, /effect clear and expiry clamp to what is
left. The command-set base and modifiers stay unsaved, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:17:00 +01:00
SenjinandClaude Opus 5.5 dddf351a05 Shader packs: at_velocity for the player's own model
Its vertices fell in the unmarked run that closes the mobs', so in third
person (and in a pack's shadow pass) its at_velocity read zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:03:19 +01:00
MrCloudy2andClaude Opus 5.5 570d53a780 A move packet counts from the position the server last reported
ClientPacketListener.handleMoveEntity decodes a move's delta from the
entity's position codec base, which AddEntity, a move and a position sync
set and a TeleportEntity leaves alone. Horizon counted it from the
tracked position, which the teleport had moved: when a command teleported
a vehicle, its rider was told the teleport and then the tracker's delta,
and saw the vehicle (and themselves) as far again past where it was.

lan_e2e gains `vehicle-teleport`: a strider the guest rides, teleported
3 blocks, is where the server has it in the guest's game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:51:29 +01:00
TheMrZZandCodex gpt-6.1-sol 8db7c3ddc0 fix: preserve plain forked function feedback order
Prepare starts for eligible actual sources before executing their owned function calls. Keep the existing body, callback, store and fallback paths, and consume or clear invocation slots around leaf execution.

Add frozen Java/Game command parity and causal store, return, error and fallback controls.

Fixes #11824

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 17:33:05 +01:00
IwantHappinessandNemotron 4cc2a04c25 Translate remaining Russian launcher and game strings
Translate still-English UI strings in launcher/lang/ru.json and the game's
crates/horizon-client/assets/horizon/lang/ru_ru.json (particle culling
settings). Keep proper nouns, acronyms, key names, paths and env vars in
English.

Co-Authored-By: Nemotron <[email protected]>
2026-11-07 19:20:43 +03:00
Florian ErnstandClaude Opus 5.5 ce25d100ef Merge origin/main into fix/mob-effect-amplifier-codec
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:15:10 +01:00
Florian ErnstandClaude Opus 5.5 8b577fc24c Merge origin/main into fix/h032-jukebox-saved-clock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:51 +01:00
Florian ErnstandClaude Opus 5.5 974305151d Merge origin/main into fix/summoned-leash-knot-lifecycle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:50 +01:00
Florian ErnstandClaude Opus 5.5 e6941baa5a Merge origin/main into fix/h195-command-damage-range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:49 +01:00
MrCloudy2andClaude Opus 5.5 d72b2a555b execute on passengers finds the players riding a vehicle on a server
Entity.getPassengers holds players as well as mobs. Horizon's `on
passengers` named the player in place when a mob had a rider flag, and no
server player: a map's datapack, running with no player in place, never
found the player riding its strider, and a command the host ran named the
host instead of the guest riding. It now gives the player in place when
they ride the entity, and each other player of the level riding it.

Mob and boat passengers are left to #11810.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:47 +01:00
Florian ErnstandClaude Opus 5.5 7cd9dbc7a2 Let Health Boost raise a mob's healing maximum and clamp when it goes
Health Boost is a max_health attribute modifier. The attribute getter
already counted it, but Mob::heal clamps to the cached attrs.max_health,
which the effect paths never refreshed, and nothing lowered Health when
the effect went (LivingEntity.onAttributeUpdated). Adding the effect,
/effect clear and its expiry now refresh the cache and lower Health above
the new maximum; the add still heals nothing.

Sulfur cubes and wolves write their maximum straight into the cache
(sulfur_cube::set_size, tame::set_tame), outside the attribute model,
and keep their current behavior. Other effect removal paths are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:10:03 +01:00
JoansitohandClaude Sonnet 5.5 6d1746d729 launcher: the Home patch log and console logs keep the wheel to themselves
Their overflow_y_scroll lists lacked the on_scroll stop_propagation every other
inner scroller has, so a wheel turn the list could not use (up at the top, or
down at the end) scrolled the whole page behind it.

Fixes #11853

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 17:04:07 +01:00
Florian ERNSTandCodex 2dd6879bc4 Keep actual bell block entities for command reads
Co-Authored-By: Codex <[email protected]>
2026-11-07 16:55:24 +01:00
8cd009e149 Read saved template structure starts before regenerating them
Part of #11126. Builds on #11652.

Decode shipwreck, ocean ruin, igloo, ruined portal, woodland mansion,
nether fossil and end city pieces from saved starts. Rebuild template
boxes and placement settings like vanilla's tag constructors, retaining
the whole-start regeneration fallback for unsupported or broken pieces.

Cover every new type with resource-free round trips and extend the
prepared-resource generation/read comparison across all three dimensions.

Piece ids go through #11652's saved_piece_id first; any other id is
matched with or without the minecraft: namespace, not lowercased, as
Identifier.parse reads it.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:33:44 +01:00
SenjinandClaude Opus 5.5 ebfcad98f6 Shader packs: at_velocity for blocks a piston moves
A moving block (and an extending or retracting head) is drawn with the entities while it
moves, and read zero at_velocity: flying machines and piston contraptions smeared under a
pack's TAA. Each marks its own run, (6 << 32) | the cell it moves to, which stays the same
while it moves; a finished one keeps that key until the chunk mesh shows the block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:25:09 +01:00
Florian ERNSTandCodex e16378accd Use world RNG for literal command predicate chances
Co-Authored-By: Codex <[email protected]>
2026-11-07 16:19:02 +01:00
Florian ERNSTandCodex 282a7005c8 Fix chorus-flower drops from arrow and snowball impacts
Co-Authored-By: Codex <[email protected]>
2026-11-07 15:40:50 +01:00
SenjinandClaude Opus 5.5 381c5a1e62 Shader packs: at_velocity for boats, minecarts, armor stands, frames, paintings, TNT, falling blocks and block entities
They were drawn in runs marked only by type, so a pack's at_velocity read zero on them even
while they or the camera moved (TAA smeared them). Each now marks its own motion run:
(3 << 32) | entity id, (4 << 32) | id for an item frame's held item, (5 << 32) | position for a
block entity, with a closing mark after each group; maps stay unmarked (keyed by map id).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:25:24 +01:00
itarqos5andClaude Opus 5 6a634e49e2 feat(render): a compact layout for far tiles' quads, a quarter of the arena
Chunk sections already put their solid quads in the arena as one 24-byte
record each instead of four 24-byte vertices (`mesher::packed`). Far tiles
never could: that record's coordinates are a block grid of 16, and a far
tile's quads are merged rectangles of a voxel column's exposed side,
hundreds of blocks wide. So they stayed at four vertices a quad, and at
`lodDistance` 256 they were by far the largest thing the renderer held.

A far quad is much more regular than a chunk's, though -- a rectangle in an
axis-aligned plane, one colour, one light and one layer across all four
corners, with UVs `lod::mesh::emit_quad` derives from the positions -- so a
record of its own holds it in the same 24 bytes, and terrain.wgsl's
`far_corner` does what that function did. The two layouts share the arena
and the pipeline; a draw's base vertex says which it is (`FAR_BASE`).

Measured on a settled LodTest at `lodDistance` 256, render distance 16, the
same binary with `HORIZON_FAR_PACK=0` and without, each run settled on the
same 4424 tiles and the same quad count to within four:

                      far vertices   arena pages   GPU allocated
    four vertices        492.5 MiB       960.0        1241.7 MiB
    a record a quad      127.4 MiB       640.0         921.8 MiB

365 MiB less vertex data, and 320 MiB less video memory actually allocated
rather than merely accounted for. 24.8 bytes a quad: a record each, and the
few quads that spill. Both figures repeated exactly over two pairs of runs.

Frame time is unchanged. Over the same flight, the mean of two pairs:

                      avg FPS   1% low   GPU avg
    four vertices       171.0     95.8    5.390 ms
    a record a quad     170.3     94.9    5.420 ms

The pairs disagree on the sign -- the compact layout was 1.5% slower in one
and 0.7% faster in the other -- so the difference is run variance, not the
cost of expanding a record. Worth stating because one pair alone read as a
regression.

Nothing here reasons about which record is right for a quad: `pack` builds
one, rebuilds the four vertices from it, and keeps it only if they come back
bit for bit. A quad no record reproduces is kept as its four vertices, as
`mesher::packed` keeps one; in practice that is the fluid faces, whose
surface sits at 8/9 of a block, and about 1% of quads.

`lod::mesh`'s `every_quad_the_mesher_emits_packs_and_comes_back` holds the
layout and the mesher to each other over real terrain, which the layout's
own tests cannot: they check its records against its own copy of what
`emit_quad` does, and would pass just as well if the two had drifted.

A same-build control says the picture does not change: two launches of the
identical build differ in 50.7% of pixels (16.0% above the animation noise
floor, worst channel delta 76), because far-tile refinement does not settle
identically between launches. Packed against unpacked differs in 40.5%
(12.8% above the floor, worst 77) -- less than the control.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-07 14:13:34 +00:00
MSedgeMC 2f7a46dfd3 port_map: the Camel.canMate row no longer records the canParent gap 2026-11-07 13:48:54 +00:00
MSedgeMC dfe4c2cb51 Test that a wounded or ridden camel cannot breed
Two camels in love in a built world: the breed goal starts, then one loses a
point of health and is refused, and a rider also refuses it. Without the
canParent guard the wounded camel still starts the goal.
2026-11-07 13:48:54 +00:00
MSedgeMC 3ed00f4071 A wounded or ridden camel no longer starts breeding
`Camel.canMate` is `partner instanceof Camel && this.canParent() &&
camel.canParent()`, and `AbstractHorse.canParent` requires no vehicle or
passenger and full health (a camel's `isTamed` is always true). The shared
`Breed` goal applied those checks only to horses, donkeys and llamas, so a
camel stayed free to breed while hurt or carried.

Camels still pair only with camels: the `camel` flag feeds the canParent
guard only, not the partner species rule.
2026-11-07 13:48:54 +00:00
Florian ERNSTandCodex 1066efbafb Normalize note-block instruments during fill
Co-Authored-By: Codex <[email protected]>
2026-11-07 14:35:22 +01:00
Florian ERNSTandCodex 2688eb16d6 Decode Mob effect amplifiers as unsigned bytes
Co-Authored-By: Codex <[email protected]>
2026-11-07 13:52:45 +01:00
bulkandClaude Sonnet 5.5 a0c5f26cc2 Far ring brake: pull in over 50 ms, grow back under 45 ms
The ring got stuck at its smallest on servers whose normal tick sits
between 25 and 40 ms, since it only grew back under 25 ms.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 12:43:37 +00:00
TheMrZZandCodex gpt-6.1-sol ac4859eab4 fix: match command damage-only item predicate ranges
Evaluate the supported damage-only subpredicate against modeled damage and
existing effective component presence. Preserve removal precedence and leave
unsupported conjunctions on their existing path.

Fixes #11715

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 12:23:52 +01:00
Florian ERNSTandCodex 087c763432 Normalize summoned leash knots and check their fence support
Co-Authored-By: Codex <[email protected]>
2026-11-07 12:23:27 +01:00
Arslan ArmanulyandClaude Opus 5.5 d3e7858cda Shader packs: GPU example fixtures give their packs terrain programs
Since a pack with no program for terrain or translucent terrain gets the
notes "no program for terrain; nothing draws it" and "no program for
translucent terrain; nothing draws it", standard_pack_gpu_entities and
entity_shadow_ownership failed their no-notes assertions: their packs had
entity programs only. Both packs now also have gbuffers_terrain and
gbuffers_water. The fixtures draw no terrain, and every other assertion is
unchanged.

Fixes #11710

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:06:18 +05:00
TheMrZZandCodex gpt-6.1-sol 6d5c7c51a6 Preserve modeled jukebox saved-song clock presence
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 11:01:17 +01:00
Vinícius MoreiraandClaude Opus 5.5 dd4528f4e4 Environment attributes: the layer stack, biome blending and weather
Ports EnvironmentAttributeSystem: every attribute is its default run
through layers in the order they are added, leading constant layers folded
into the base (bakeLayerSampler) and the result sanitized. The dimension
type's attributes are constant layers; the biome layer reads the biome at
the position, or for spatially interpolated attributes the
SpatialAttributeInterpolator mix that GaussianSampler accumulates; the
weather layers (WeatherAttributes.RAIN and THUNDER) blend by the rain and
thunder levels with each type's state-change lerp. Timelines plug in as
time layers and come next; nothing in the game uses the stack yet.

Entries of types not ported yet (append, overlay) are kept as no-ops so a
biome's other attributes still load.

The new harness environment_system compares 900 stacks with the pinned
client (36,000 lines), with the vanilla biome and dimension type data.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:38:15 -03:00
Horizon agent ab6900b4c9 Merge main into #10283
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-07 01:57:18 +01:00
Horizon agent 58e32ea813 Merge main into #10280 2026-11-07 01:53:40 +01:00
MSedgeMC 4304cc44b5 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 13:39:30 +00:00
Horizon agentandClaude Opus 5.5 96c7d68d0d Unloading a chunk takes its lingering clouds with it
entity_chunks(remove = true) is the unload path: every other kind it
writes is also taken out of the level, and the potion clouds were not,
so a chunk loaded again got a second cloud on the same spot. The new
test unloads and reloads a cloud and expects one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:04 +01:00
Horizon agentandClaude Opus 5.5 5988321440 Jukebox: post jukebox_stop_play only while a song plays
JukeboxSongPlayer.stop posts the event inside `if (this.song != null)`:
taking a disc out (by hand or by a hopper) after its song ended is silent.
The test takes the finished disc out and expects only the end's event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:11:02 +01:00
Horizon agent 47dc19fa20 Merge main into #10283 2026-11-06 12:54:39 +01:00
Horizon agent 9141f1d538 Merge main into #10280 2026-11-06 12:54:33 +01:00
MSedgeMC a33e5b574a The ledger knows a cloud's save is ported
AreaEffectCloud.addAdditionalSaveData was listed missing, which was right: only
the reader existed. game/potions.rs::potion_cloud_to_nbt writes it now, from
entity_chunks.
2026-11-05 22:12:59 +00:00
MSedgeMC 78cf6e36a0 Test that a lingering cloud survives a save
A cloud with a poison and a slowness effect, one of them hidden and one ambient,
written with potion_cloud_to_nbt and read back with potion_cloud_from_nbt: the
position, age, duration, radius, the four use settings, the duration scale,
RadiusPerTick, the effect list and the colour all come back. The test also
checks that the compound carries the field names vanilla's
readAdditionalSaveData looks for, and that it is saved as
minecraft:area_effect_cloud.
2026-11-05 22:12:59 +00:00
MSedgeMC bd991e7917 A lingering potion's cloud survives a save
AreaEffectCloud.addAdditionalSaveData writes the cloud's age, duration, radius,
the four use settings, RadiusPerTick, potion_duration_scale and its
potion_contents, and the client already read every one of them back
(game/potions.rs::load_potion_cloud): a cloud that was loaded from a save came
back whole. Nothing ever wrote one, though. entity_chunks writes the dragon, the
crystals, the item frames, the fangs and the rest kind by kind, and the
lingering clouds -- which live in breath_clouds, not in entity_lists! -- were in
none of those, so every replacement pass dropped the cloud a thrown potion or a
creeper left behind.

The save side now writes them: game/potions.rs::potion_cloud_to_nbt builds the
compound on the entity fields entity_nbt::entity_base_tag gives it, and
entity_chunks adds one tag per potion-bearing cloud of the chunk. A dragon's own
breath carries no potion and is not written.

The reader moved out of the Game method into potion_cloud_from_nbt, so the pair
can be tested against itself. The client keeps no potion id, so the effects are
written as custom_effects with the colour the cloud was drawn in, and the custom
particle and owner reference the vanilla cloud also saves have no counterpart
here.
2026-11-05 22:12:58 +00:00
MSedgeMC 18f1a16d12 test: a jukebox that stops posts jukebox_stop_play
A windowless game with a jukebox at (0, 64, 0): a disc put in and popped back
out posts one G 0,64,0 minecraft:jukebox_stop_play, a song ticked to its end
posts one, a broken jukebox posts none from this path (the world's spill posts
it) and a jukebox that never held a disc posts nothing.
2026-11-05 22:04:17 +00:00
MSedgeMC c7fc74cb26 fix: a jukebox that stops playing tells the listeners
JukeboxSongPlayer.stop posts GameEvent.JUKEBOX_STOP_PLAY at the block (and level
event 1011), and both ways a song ends reach it: popOutTheItem, when the disc is
taken out or a hopper pulls it, and tick's own hasFinished branch.

Horizon posted that event only from the world's removal path, so a broken
jukebox was heard but a jukebox whose disc was pulled out by hand, or whose song
ran out, was silent to a sculk sensor beside it. The paths that keep the block
now go through stop_song_heard; jukebox_removed keeps the plain stop_song,
because the world's spill already posts the same event once for it.
2026-11-05 22:04:17 +00:00
576 changed files with 47949 additions and 4921 deletions

No files matched your search

+1 -1
View File
@@ -168,7 +168,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
+8
View File
@@ -309,3 +309,11 @@ Permission to use, copy, modify, and distribute this
software is freely granted, provided that this notice
is preserved.
```
## Starling
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
+41 -3
View File
@@ -412,10 +412,11 @@ pub fn to_text_with(
.collect()
})
.unwrap_or_default();
let translated = Text::translate(key, &args);
// TranslatableContents.decompose: Language.getOrDefault(key, fallback), so the
// fallback is the template itself, never looked up as another key.
match o.get("fallback").and_then(Value::as_str) {
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
_ => translated,
Some(fallback) => Text::translate_with_fallback(key, fallback, &args),
None => Text::translate(key, &args),
}
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
@@ -513,6 +514,43 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
impl ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
/// TranslatableContents.decompose: Language.getOrDefault(key, fallback). A missing key
/// shows the fallback as written, even when the fallback is another key the language has.
#[test]
fn a_translatable_fallback_stays_literal() {
let pack = Mem([(
"assets/minecraft/lang/en_us.json".to_owned(),
br#"{"real.key": "Translated", "greet": "Hello %s"}"#.to_vec(),
)]
.into());
let ((), _) = lang::scoped(Language::load(&[&pack], "en_us"), || {
let parse = |v: Value| to_text(&v, &mut |_| Text::empty()).plain();
let missing = serde_json::json!({"translate": "missing.key", "fallback": "real.key"});
assert_eq!(parse(missing), "real.key");
// The fallback is a template: its arguments are filled in.
let args = serde_json::json!({"translate": "missing.key", "fallback": "Hi %s", "with": ["Steve"]});
assert_eq!(parse(args), "Hi Steve");
// A key the language has wins over its fallback.
let found =
serde_json::json!({"translate": "greet", "fallback": "real.key", "with": ["Alex"]});
assert_eq!(parse(found), "Hello Alex");
// A component built with the constructor keeps its meaning through its JSON.
let built = Text::translate_with_fallback("missing.key", "real.key", &[]);
let again = to_text(&built.component(), &mut |_| Text::empty());
assert_eq!(again.plain(), built.plain());
assert_eq!(again.component(), built.component());
});
}
/// SnbtGrammar: a compound or list may end with one comma; two in a row
/// stay an error.
#[test]
+1 -20
View File
@@ -1,6 +1,7 @@
//! NBT paths (`NbtPathArgument`): `foo.bar[0]`, `[]`, `list[{id:1}]`,
//! `{root:match}`, with get, set, insert and remove on a tag tree, and the
//! partial NBT comparison (`NbtUtils.compareNbt`) selectors and conditions use.
pub use horizon_world::nbt::compare_nbt;
use horizon_world::nbt::{Compound, JavaString, Tag, parse_snbt};
/// An error carrying the translation key and its arguments.
@@ -47,26 +48,6 @@ fn get_child<'a>(c: &'a Compound, name: &str) -> Option<&'a Tag> {
c.get(&key(name))
}
/// `NbtUtils.compareNbt(expected, actual, true)`.
pub fn compare_nbt(expected: &Tag, actual: Option<&Tag>) -> bool {
let Some(actual) = actual else { return false };
if std::mem::discriminant(expected) != std::mem::discriminant(actual) {
return false;
}
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => {
a.len() >= e.len() && e.iter().all(|(k, v)| compare_nbt(v, a.get(k)))
}
(Tag::List(e), Tag::List(a)) => {
if e.is_empty() {
return a.is_empty();
}
a.len() >= e.len() && e.iter().all(|x| a.iter().any(|y| compare_nbt(x, Some(y))))
}
_ => expected == actual,
}
}
// ---- list-like tags ----------------------------------------------------------
/// The elements of a list or array tag.
@@ -89,6 +89,31 @@ pub struct Registries {
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
/// The `ConfiguredFeature` codec for an inline `minecraft:worldgen/feature`
/// value (`ResourceOrIdArgument`), where the source has the server's live
/// features; None leaves the decode to when the command runs.
pub inline_feature: Option<InlineFeatureCheck>,
}
/// A codec check of an inline value: its error message when it does not decode.
#[derive(Clone)]
pub struct InlineFeatureCheck(
std::sync::Arc<dyn Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync>,
);
impl InlineFeatureCheck {
pub fn new(
check: impl Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync + 'static,
) -> Self {
Self(std::sync::Arc::new(check))
}
pub fn check(&self, value: &horizon_world::nbt::Tag) -> Result<(), String> {
(self.0)(value)
}
}
impl std::fmt::Debug for InlineFeatureCheck {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InlineFeatureCheck(..)")
}
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
+8 -1
View File
@@ -583,6 +583,10 @@ pub struct PadSettings {
/// The sneak button flips sneaking (Bedrock's default) instead of
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
/// LT + A on a recipe crafts on every server, not only in single player and on LAN worlds
/// found in the server list. Off by default: a server's rules may count a repeated craft as a
/// macro.
pub craft_on_servers: bool,
pub glyphs: GlyphStyle,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
@@ -614,6 +618,7 @@ impl Default for PadSettings {
look_deadzone: 0.12,
trigger_threshold: 0.4,
toggle_sneak: true,
craft_on_servers: false,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
@@ -684,6 +689,7 @@ impl PadSettings {
s.trigger_threshold = unit(s.trigger_threshold).clamp(0.1, 0.9)
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"craftOnServers" => s.craft_on_servers = flag(s.craft_on_servers),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
@@ -745,7 +751,8 @@ impl PadSettings {
self.keyboard.name(),
);
text.push_str(&format!(
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
"craftOnServers:{}\ngameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
bool_text(self.craft_on_servers),
self.guide_detail.name(),
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
@@ -32,6 +32,11 @@ fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_s
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
write("gbuffers_entities.vsh", VERTEX);
write("gbuffers_entities.fsh", COLOUR);
// Terrain programs keep the load free of "nothing draws it" notes; no terrain is drawn.
for terrain in ["gbuffers_terrain", "gbuffers_water"] {
write(&format!("{terrain}.vsh"), VERTEX);
write(&format!("{terrain}.fsh"), COLOUR);
}
if let Some(shadow) = shadow {
write("shadow.vsh", VERTEX);
write("shadow.fsh", shadow);
@@ -661,6 +661,7 @@ fn remesh(
blend_radius,
smooth_lighting,
cutout_leaves,
opaque_ice: false,
translucent_layers: &[],
leaf_culling,
};
@@ -50,10 +50,14 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
// The terrain programs keep the load free of "nothing draws it" notes; this
// fixture draws no terrain.
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
"gbuffers_terrain",
"gbuffers_water",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
@@ -1604,6 +1604,14 @@ impl Audio {
let id = self.engine.add(instance, false);
self.engine.play_delayed(id, ticks);
}
/// `SoundManager.playDelayed` of an instance the caller made: it starts
/// `ticks` sound-engine ticks from now.
pub fn play_instance_after(&mut self, instance: SoundInstance, ticks: i32) {
*self.heard.entry(instance.identifier.clone()).or_default() += 1;
self.sync_volumes();
let id = self.engine.add(instance, false);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -0,0 +1,314 @@
//! GPU Culling's Auto mode: the setting switched by what limits the frame, measured here.
//!
//! GPU Culling moves the walk over the near sections and the listing of their draws from the
//! render thread to the GPU. That pays where the CPU is the slower of the two and costs where
//! the GPU is, and which a machine is can't be told from its parts: a fast CPU with a modest
//! GPU, a slow one with a strong GPU and every mix between depend on the scene, the render
//! distance and the resolution. So Auto watches the frames' own CPU and GPU times, and only
//! where the CPU is clearly the slower one it tries GPU Culling, measures the frame again, and
//! keeps it if the frame got faster. A trial that didn't help is undone and not repeated for
//! minutes of play, then for longer, then not until the next start, so a machine or a scene it
//! doesn't suit loses a few frames' time a few times, not every half minute.
//!
//! A verdict is the medians of a window of frames (`WINDOW`), which a few slow frames (a chunk
//! load, say) don't move. Nothing here reads the GPU or draws anything: the caller feeds it the
//! frames and applies what it answers.
/// Frames in one verdict.
pub const WINDOW: usize = 240;
/// The CPU's time must be this many times the GPU's for a window to count as CPU-bound.
const CPU_BOUND: f32 = 1.3;
/// Below this CPU time, in milliseconds, a frame is fast enough that neither matters.
const WORTH_TRYING_MS: f32 = 1.5;
/// A trial is kept when the frame's busier side dropped under this share of what it was.
const KEEP_BELOW: f32 = 0.97;
/// Windows of CPU-bound frames in a row before a trial.
const STREAK: u8 = 2;
/// Windows left out after a switch, while its effects settle.
const SETTLE: u8 = 1;
/// How long, in milliseconds of frames watched, after the first and the second trial that didn't
/// help before another; after a third there is none.
const RETRY_AFTER_MS: [f64; 2] = [5.0 * 60_000.0, 20.0 * 60_000.0];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
/// Off, looking for a CPU-bound frame.
Watching,
/// On for a trial: left out `SETTLE` windows, then measured against the frame before.
Trial,
/// The trial made the frame faster: on from here.
Kept,
}
/// What Auto has decided and why, a step at a time.
#[derive(Clone, Debug)]
pub struct GpuCullingAuto {
cpu: Vec<f32>,
gpu: Vec<f32>,
on: bool,
state: State,
/// CPU-bound windows in a row.
streak: u8,
/// The frame's busier side before the trial, in milliseconds.
before: f32,
/// Windows still left out after a switch.
settling: u8,
/// The frames watched so far, in milliseconds of their own time.
clock_ms: f64,
/// When the next trial may start, on that clock.
retry_at_ms: f64,
/// Trials that didn't help.
rejected: usize,
}
impl Default for GpuCullingAuto {
fn default() -> Self {
Self::new()
}
}
impl GpuCullingAuto {
pub fn new() -> Self {
Self {
cpu: Vec::with_capacity(WINDOW),
gpu: Vec::with_capacity(WINDOW),
on: false,
state: State::Watching,
streak: 0,
before: 0.0,
settling: 0,
clock_ms: 0.0,
retry_at_ms: 0.0,
rejected: 0,
}
}
/// Whether Auto has GPU Culling on now.
pub fn on(&self) -> bool {
self.on
}
/// Where Auto is, for the status: `watching`, `trial` or `kept`.
pub fn state(&self) -> &'static str {
match self.state {
State::Watching => "watching",
State::Trial => "trial",
State::Kept => "kept",
}
}
/// Back to the start: off, with nothing measured (the mode changed, or the world did).
pub fn reset(&mut self) {
*self = Self::new();
}
/// One frame's CPU time, a GPU time where its timestamps have arrived, and its whole time,
/// in milliseconds. Answers the new state when this frame completed a window that changed it.
pub fn observe(&mut self, cpu_ms: f32, gpu_ms: Option<f32>, frame_ms: f32) -> Option<bool> {
self.clock_ms += f64::from(frame_ms);
self.cpu.push(cpu_ms);
if let Some(gpu) = gpu_ms {
self.gpu.push(gpu);
}
if self.cpu.len() < WINDOW {
return None;
}
let cpu = median(&mut self.cpu);
// A GPU that can't be timed leaves nothing to compare with: Auto stays as it is.
let gpu = (self.gpu.len() >= WINDOW / 4).then(|| median(&mut self.gpu));
self.cpu.clear();
self.gpu.clear();
self.verdict(cpu, gpu?)
}
fn verdict(&mut self, cpu: f32, gpu: f32) -> Option<bool> {
if self.settling > 0 {
self.settling -= 1;
return None;
}
match self.state {
State::Kept => None,
State::Watching => {
if self.clock_ms < self.retry_at_ms {
return None;
}
let bound = cpu >= WORTH_TRYING_MS && cpu > gpu * CPU_BOUND;
self.streak = if bound { self.streak + 1 } else { 0 };
if self.streak < STREAK {
return None;
}
self.streak = 0;
self.before = cpu.max(gpu);
self.state = State::Trial;
self.settling = SETTLE;
self.on = true;
Some(true)
}
State::Trial => {
if cpu.max(gpu) < self.before * KEEP_BELOW {
self.state = State::Kept;
return None;
}
self.state = State::Watching;
self.retry_at_ms = RETRY_AFTER_MS
.get(self.rejected)
.map_or(f64::INFINITY, |wait| self.clock_ms + wait);
self.rejected += 1;
self.on = false;
Some(false)
}
}
}
}
fn median(values: &mut [f32]) -> f32 {
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
/// A window of frames at constant times, each as long as its busier side; the last answer.
fn window(auto: &mut GpuCullingAuto, cpu: f32, gpu: f32) -> Option<bool> {
(0..WINDOW)
.filter_map(|_| auto.observe(cpu, Some(gpu), cpu.max(gpu)))
.last()
}
/// A machine whose CPU limits the frame is tried, and keeps GPU Culling when the frame
/// gets faster.
#[test]
fn a_cpu_bound_frame_tries_it_and_keeps_it_when_it_helps() {
let mut auto = GpuCullingAuto::new();
assert!(!auto.on());
// One CPU-bound window is not yet a verdict.
assert_eq!(window(&mut auto, 5.0, 2.0), None);
assert_eq!(window(&mut auto, 5.0, 2.0), Some(true));
assert!(auto.on());
assert_eq!(auto.state(), "trial");
// The window after the switch is left out, whatever it shows.
assert_eq!(window(&mut auto, 9.0, 9.0), None);
// The frame's busier side went from 5.0 to 3.0 ms.
assert_eq!(window(&mut auto, 3.0, 2.6), None);
assert_eq!(auto.state(), "kept");
assert!(auto.on());
// Kept stays kept, even if a later scene is GPU-bound.
assert_eq!(window(&mut auto, 1.0, 8.0), None);
assert!(auto.on());
}
/// Where it didn't help, it is undone, and not tried again for 5 minutes, then 20, then
/// not at all: a scene it doesn't suit is not tried every half minute.
#[test]
fn a_trial_that_does_not_help_is_undone_and_waits_longer_each_time() {
let mut auto = GpuCullingAuto::new();
// One window is 240 frames of 5 ms: 1.2 s. A trial, and its rejection:
let reject = |auto: &mut GpuCullingAuto| {
window(auto, 5.0, 2.0);
assert_eq!(window(auto, 5.0, 2.0), Some(true));
window(auto, 5.0, 2.0);
// The GPU took over the frame: 5.0 ms before, 5.4 after.
assert_eq!(window(auto, 2.5, 5.4), Some(false));
assert!(!auto.on());
assert_eq!(auto.state(), "watching");
};
reject(&mut auto);
// CPU-bound as ever, and left alone for 5 minutes of frames: 250 windows.
let mut waited = 0;
while window(&mut auto, 5.0, 2.0).is_none() {
waited += 1;
}
assert!((248..=252).contains(&waited), "{waited}");
// That window started the second trial; it is rejected too.
window(&mut auto, 5.0, 2.0);
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
// Now 20 minutes: 1000 windows.
let mut waited = 0;
while window(&mut auto, 5.0, 2.0).is_none() {
waited += 1;
}
assert!((998..=1002).contains(&waited), "{waited}");
window(&mut auto, 5.0, 2.0);
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
// A third rejection is for good.
for _ in 0..5000 {
assert_eq!(window(&mut auto, 5.0, 2.0), None);
}
assert!(!auto.on());
}
/// A GPU-bound machine is never switched: its frame is the GPU's.
#[test]
fn a_gpu_bound_frame_is_left_alone() {
let mut auto = GpuCullingAuto::new();
for _ in 0..20 {
assert_eq!(window(&mut auto, 0.5, 3.6), None);
}
assert!(!auto.on());
}
/// Within the margin neither side limits the frame, and a single CPU-bound window among
/// others breaks no streak into a trial.
#[test]
fn neither_a_balanced_nor_an_occasional_cpu_bound_frame_is_tried() {
let mut auto = GpuCullingAuto::new();
for _ in 0..10 {
assert_eq!(window(&mut auto, 3.0, 2.6), None);
}
for _ in 0..10 {
assert_eq!(window(&mut auto, 5.0, 2.0), None);
assert_eq!(window(&mut auto, 2.0, 2.0), None);
}
assert!(!auto.on());
}
/// Fast frames aren't worth a trial, bound or not.
#[test]
fn a_frame_under_the_floor_is_not_tried() {
let mut auto = GpuCullingAuto::new();
for _ in 0..10 {
assert_eq!(window(&mut auto, 1.2, 0.2), None);
}
assert!(!auto.on());
}
/// Without GPU times (a GPU that can't be timed) there is nothing to compare.
#[test]
fn frames_without_gpu_times_decide_nothing() {
let mut auto = GpuCullingAuto::new();
for _ in 0..WINDOW * 10 {
assert_eq!(auto.observe(8.0, None, 8.0), None);
}
assert!(!auto.on());
}
/// A spike in a window doesn't move its verdict: the median ignores it.
#[test]
fn a_few_slow_frames_do_not_make_a_window_cpu_bound() {
let mut auto = GpuCullingAuto::new();
for _ in 0..4 {
let mut answer = None;
for i in 0..WINDOW {
// One frame in ten is a 30 ms chunk load, the rest are GPU-bound.
let cpu = if i % 10 == 0 { 30.0 } else { 0.8 };
answer = answer.or(auto.observe(cpu, Some(3.0), 3.0));
}
assert_eq!(answer, None);
}
assert!(!auto.on());
}
#[test]
fn reset_starts_over() {
let mut auto = GpuCullingAuto::new();
window(&mut auto, 5.0, 2.0);
window(&mut auto, 5.0, 2.0);
assert!(auto.on());
auto.reset();
assert!(!auto.on());
assert_eq!(auto.state(), "watching");
assert_eq!(window(&mut auto, 5.0, 2.0), None);
}
}
+25 -1
View File
@@ -1,6 +1,7 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_client_core::particle_type::ParticleType;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{
@@ -106,11 +107,28 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `minecraft:visual/ambient_particles` entries and their probabilities.
pub ambient_particles: Vec<(ParticleType, f32)>,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
let list = &v["attributes"]["minecraft:visual/ambient_particles"];
let entries = list["argument"].as_array().or_else(|| list.as_array());
entries
.into_iter()
.flatten()
.filter_map(|entry| {
Some((
ParticleType::from_name(entry["particle"]["type"].as_str()?)?,
entry["probability"].as_f64().unwrap_or(0.0) as f32,
))
})
.collect()
}
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
@@ -245,6 +263,7 @@ impl BiomeInfo {
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
ambient_particles: Self::ambient_particles(v),
features: v["features"]
.as_array()
.into_iter()
@@ -563,7 +582,9 @@ mod pack_tests {
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"},
"attributes": {"minecraft:visual/ambient_particles": {
"argument": [{"particle": {"type": "minecraft:ash"}, "probability": 0.25}]}}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
@@ -576,6 +597,9 @@ mod pack_tests {
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
assert_eq!(world.get(2).ambient_particles.len(), 1);
assert_eq!(world.get(2).ambient_particles[0].0.name(), "ash");
assert_eq!(world.get(2).ambient_particles[0].1, 0.25);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
+1 -1
View File
@@ -576,7 +576,7 @@ pub struct Chunk {
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), String)>,
pub block_names: Vec<((i32, i32, i32), Tag)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
/// positions, applied once by LevelChunk.postProcessGeneration.
pub postprocess: Vec<[i32; 3]>,
@@ -83,6 +83,8 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
Bell,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
@@ -452,6 +454,9 @@ pub struct Jukebox {
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
/// `JukeboxBlockEntity.saveAdditional` writes the clock only when its song
/// reference exists. Keep this separate from legacy playback adoption.
pub song_clock_present: bool,
pub playing: bool,
}
impl Jukebox {
+70 -13
View File
@@ -19,26 +19,75 @@ pub struct ModMenuType {
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
/// its crafting grid, if it has one (`crafting`).
pub crafting: Option<ModCrafting>,
/// its linked slots, if it has any (`links`).
pub links: Option<ModLinks>,
}
/// a mod menu type's linked slots: `count` container slots from `from` on,
/// which show the slots of the containers a native mod links them to
/// (`native_menu_links`) instead of the block entity's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModLinks {
pub from: usize,
pub count: usize,
}
impl ModLinks {
/// The linked container slots.
pub fn slots(&self) -> std::ops::Range<usize> {
self.from..self.from + self.count
}
}
/// a mod menu type's crafting grid: `size`x`size` container slots from
/// `grid` on, row by row, and the slot showing what they craft. The block
/// entity holds the grid; the result is the menu's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModCrafting {
pub grid: usize,
pub size: usize,
pub result: usize,
}
impl ModCrafting {
/// The grid's container slots.
pub fn grid_slots(&self) -> std::ops::Range<usize> {
self.grid..self.grid + self.size * self.size
}
}
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
/// registers the content mods' menu types, `(id, slots, data slots,
/// results)`, a list set once per process (false when it already was).
pub fn register_mod_menu_types(
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
) -> bool {
/// One menu type as [`register_mod_menu_types`] takes it: `(id, slots, data
/// slots, results, crafting, links)`.
pub type ModMenuTypeEntry = (
String,
usize,
usize,
Vec<usize>,
Option<ModCrafting>,
Option<ModLinks>,
);
/// registers the content mods' menu types, a list set once per process
/// (false when it already was).
pub fn register_mod_menu_types(menus: impl IntoIterator<Item = ModMenuTypeEntry>) -> bool {
if MOD_MENU_TYPES.get().is_some() {
return false;
}
let menus = menus
.into_iter()
.map(|(id, slots, data_slots, results)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
});
let menus =
menus.into_iter().map(
|(id, slots, data_slots, results, crafting, links)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
crafting,
links,
},
);
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
@@ -392,6 +441,9 @@ impl BlockEntity {
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:bell" {
return Some(Self::Bell);
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -487,6 +539,7 @@ impl BlockEntity {
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
@@ -525,6 +578,7 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
@@ -559,6 +613,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -589,6 +644,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
@@ -624,6 +680,7 @@ impl BlockEntity {
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
@@ -191,6 +191,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
Opt::PadGameplayGuide,
"Show the buttons for what you can do, while you play with a controller.",
),
option(
Opt::PadCraftOnServers,
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
),
option(
Opt::PadTriggerThreshold,
"How far a trigger is pulled before it counts as pressed.",
@@ -344,6 +348,7 @@ impl Item {
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
Item::Option(Opt::PadCraftOnServers) => Some(c.craft_on_servers),
_ => None,
}
}
+1 -1
View File
@@ -1149,7 +1149,7 @@ mod tests {
for pack in ["void", "islands"] {
let dir = game.join(NEW_WORLD_PACKS).join(pack);
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
std::fs::write(dir.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(dir.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
}
let (temp, ids) = seed_create_world(&game).unwrap();
+35 -4
View File
@@ -33,7 +33,10 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -276,6 +279,18 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_written_color(in, front);
}
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
let color = entity_color(in, front);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
}
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
@@ -358,15 +373,23 @@ fn shadow_color(in: Out) -> vec4<f32> {
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_entity_mark(in));
}
fn hdr_entity_mark(in: Out) -> f32 {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
// Additive and procedural colour draws can survive where the legacy mark pass skips.
if texel.a < 0.1 { return 1.0; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
return hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged));
}
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
return hdr_mark(in.tier, y);
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -407,7 +430,11 @@ fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
return celestial_color(in);
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return color;
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
@@ -424,7 +451,11 @@ fn celestial_color(in: Out) -> vec4<f32> {
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
@@ -233,6 +233,35 @@ mod tests {
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
}
/// Extras has no Undo: a change leaves none to press or reach.
#[test]
fn a_change_on_extras_leaves_no_undo() {
use crate::settings_shell::{Columns, Target};
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let at = shell.point_at(&s, SIZE, ExtraRow::Item(Item::Sort));
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(s.extras.sort && !shell.changed());
let cols = Columns::of(SIZE.0, SIZE.1).with_note_lines(NOTE_LINES);
let [x, y, ..] = cols.button(1);
assert!(
shell
.click(&mut s, (x + 2.0, y + 2.0), SIZE, false, false, false)
.is_empty()
);
assert!(s.extras.sort);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focused() == Some(Target::Undo)
});
assert!(!reached);
}
/// A page with no page list has Done under the search field: Down goes to the first row
/// instead, from the keys and, as the frame declares it, from a controller's focus.
#[test]
+379 -64
View File
@@ -32,7 +32,15 @@ const TERRAIN_ATTRIBUTES: [wgpu::VertexAttribute; 5] =
/// entity.wgsl after the OverlayTexture constants it reads.
fn entity_shader() -> String {
crate::overlay::wgsl_constants() + include_str!("entity.wgsl")
crate::overlay::wgsl_constants() + include_str!("hdr_light.wgsl") + include_str!("entity.wgsl")
}
fn written_lane(layered: bool, format: wgpu::TextureFormat, pack: bool, world: bool) -> bool {
layered && format == LINEAR_FRAME && !pack && world
}
fn terrain_shader() -> String {
include_str!("hdr_light.wgsl").to_owned() + include_str!("terrain.wgsl")
}
/// The static block-entity vertex entry point shares entity shading but lives
@@ -162,6 +170,7 @@ mod arena;
pub mod capture;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
mod cull;
mod end_sky;
pub mod entity_skin;
@@ -431,6 +440,9 @@ struct Globals {
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
/// Headroom, effective scene gain, world/look and debug, from the resolve pass's snapshot.
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -515,6 +527,9 @@ struct SectionGpu {
/// (`packed`, `mesher::packed`) a record each and the vertices of those without one.
solid_slots: u64,
packed: bool,
/// Its records are a far tile's (`lod::packed`), not a section's: the two
/// layouts are told apart by the base vertex its draws use.
packed_far: bool,
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
sides: Sides,
translucent: u32,
@@ -588,14 +603,19 @@ impl SectionGpu {
self.solid > 0 && (0..sections as i32).contains(&key.1)
}
/// The base vertex of its solid draws: quad `q`'s corners are `base + 4 * q` on (the
/// quad indices). In the compact layout they name its record's slot past
/// `PACKED_BASE` (terrain.wgsl's `section_corner`).
/// quad indices). In a compact layout they name its record's slot past
/// `PACKED_BASE`, or past `FAR_BASE` for a far tile's records, which is how
/// terrain.wgsl's `section_corner` tells the two apart.
fn solid_base(&self) -> u32 {
if self.packed {
packed::PACKED_BASE + 4 * self.start as u32
} else {
self.start as u32
if !self.packed {
return self.start as u32;
}
let base = if self.packed_far {
crate::lod::packed::FAR_BASE
} else {
packed::PACKED_BASE
};
base + 4 * self.start as u32
}
/// GPU culling's record of it at `key`, its quads within `bounds`
/// (`SectionMesh::bounds`) and in their buckets by facing side (`sides`),
@@ -1214,6 +1234,8 @@ pub struct Renderer {
lines_secondary: wgpu::RenderPipeline,
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
core_lines: Option<core_lines::CoreLines>,
/// The packs' `core/sky` and `core/stars`, drawn in place of the native sky and stars.
core_sky: Option<core_sky::CoreSky>,
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
@@ -1481,6 +1503,9 @@ pub struct Renderer {
frame_format: wgpu::TextureFormat,
/// The no-pack float HDR lane, with independent blending and an R8 coverage target.
hud_coverage: bool,
/// What `frame_format` was picked from besides the settings and the pack: whether the
/// surface offers HDR output, the device draws the 16-bit frame, and it draws the float one.
frame_device: (bool, bool, bool),
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
@@ -1489,6 +1514,7 @@ pub struct Renderer {
hdr_marker: Option<std::path::PathBuf>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
hdr_written: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// A `shaders/` pack whose pipelines build on a thread of their own (`load_shader_pack`).
@@ -1510,6 +1536,9 @@ pub struct Renderer {
/// The block sprites as one atlas, while a shader pack is loaded.
pack_atlas: Option<pack_atlas::PackAtlas>,
pack_material_atlases: Option<[pack_atlas::PackAtlas; 2]>,
/// The shared atlas's LabPBR normal and specular paged maps (`set_shared_material_maps`),
/// which shader packs read for entities, items and the hand.
shared_material_maps: [Option<wgpu::Texture>; 2],
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
@@ -2473,6 +2502,23 @@ impl Renderer {
}
levels
}
/// Whether solid quads go to the arena in the compact layout
/// (`packed_quads`), and whether far tiles' own records are on
/// (`lod::packed::far_packing`).
pub fn packed_layout(&self) -> [bool; 2] {
[self.packed_quads, crate::lod::packed::far_packing()]
}
/// Bytes of the arena the resident chunk sections hold, and of the one far
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
/// compact layouts can be seen working.
pub fn vertex_bytes_split(&self) -> [u64; 2] {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
[
self.sections.values().map(used).sum(),
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
]
}
/// Bytes of the section vertex arenas' buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
@@ -2778,7 +2824,7 @@ impl Renderer {
let gpu_timer = timing::GpuTimer::new(&device, &queue);
let shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon terrain"),
source: wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(terrain_shader().into()),
});
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
binding,
@@ -2917,6 +2963,14 @@ impl Renderer {
depth_test: bool,
blend: Option<wgpu::BlendState>,
coverage: bool| {
let mut constants = constants.to_vec();
let plain = constants
.iter()
.any(|&(k, v)| k == "HDR_LIGHTING" && v == 0.0);
constants.push((
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME && !plain)),
));
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -2947,7 +3001,7 @@ impl Renderer {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -2963,7 +3017,7 @@ impl Renderer {
hud_coverage::pipeline(
&device,
&desc,
wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
wgpu::ShaderSource::Wgsl(terrain_shader().into()),
)
.expect("the debug crosshair's coverage")
} else {
@@ -3922,7 +3976,13 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
blend: Some(blend),
@@ -4035,7 +4095,7 @@ impl Renderer {
// writes, culled like every world text pipeline (TEXT_SNIPPET keeps
// RenderPipeline.Builder's default): a name tag or text display hides
// its back.
let see_through_pipeline = entity_pipeline(
let see_through_pipeline = entity_pipeline_with(
"See-through text",
Some(wgpu::Face::Back),
false,
@@ -4043,11 +4103,13 @@ impl Renderer {
Default::default(),
alpha_blend,
false,
"fs_entity_unmarked",
wgpu::ColorWrites::ALL,
);
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
// background win against the surface they lie on (bias 1, 10), from
// the front only.
let text_polygon_offset_pipeline = entity_pipeline(
let text_polygon_offset_pipeline = entity_pipeline_with(
"Polygon offset text",
Some(wgpu::Face::Back),
true,
@@ -4059,6 +4121,8 @@ impl Renderer {
},
alpha_blend,
false,
"fs_entity_unmarked",
wgpu::ColorWrites::ALL,
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
@@ -4105,7 +4169,7 @@ impl Renderer {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &static_entity_shader,
entry_point: Some("fs_entity"),
entry_point: Some("fs_entity_unmarked"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
@@ -4212,7 +4276,13 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
blend: Some(wgpu::BlendState {
@@ -4304,7 +4374,13 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some("fs_sky_celestial"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
@@ -4605,6 +4681,10 @@ impl Renderer {
});
let static_entity_index_quads = 1 << 14;
let static_entity_indices = Self::quad_indices(&device, static_entity_index_quads);
let indirect_execution = adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let flat_wanted = flat::wanted(&adapter, &device);
// Metal draws solid terrain in two passes (`split_solid`): meshes put all
// their opaque quads first, so each pass draws its part in a run or two.
@@ -4643,6 +4723,7 @@ impl Renderer {
&bindings,
arena.page_vertices(),
min_sections,
indirect_execution,
|layout, label, vertex, fragment, blend, cull, pulled| {
let buffers: &[Option<wgpu::VertexBufferLayout<'_>>] =
if pulled { &[] } else { &buffers };
@@ -4703,10 +4784,7 @@ impl Renderer {
let multi_draw = device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
&& adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
&& indirect_execution;
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
crate::graphics::offer_translucent_batching(batchable);
let translucent_batching = batchable && translucent_batching;
@@ -4865,6 +4943,7 @@ impl Renderer {
lines,
lines_secondary,
core_lines: None,
core_sky: None,
debug_lines_opaque,
debug_lines,
debug_lines_on_top,
@@ -5027,10 +5106,12 @@ impl Renderer {
entity_atlas: atlas_view.clone(),
hdr: None,
hdr_lighting_setting: true,
hdr_written: false,
emission_stats,
hdr_output,
frame_format,
hud_coverage,
frame_device: (hdr_output.is_some(), wide_ok, linear.2),
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
@@ -5044,6 +5125,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_material_atlases: None,
shared_material_maps: [None, None],
pack_terrain_flat: false,
separate_ao: false,
pack_lava_layers: Vec::new(),
@@ -5087,7 +5169,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
// Drawn into the world: the light sources it holds are marked as the world's are.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
@@ -5095,7 +5177,7 @@ impl Renderer {
}
/// HDR lighting on the clouds just drawn: the sky's value (`fs_hdr_cloud_mark`).
fn mark_clouds(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
pass.set_pipeline(&self.hdr_cloud_mark_pipeline);
@@ -5114,7 +5196,7 @@ impl Renderer {
solid_draws: u32,
translucent_draws: u32,
) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
if self.hdr_written || self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
match flat {
@@ -5135,6 +5217,47 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// The shared atlas's LabPBR maps (`HudAtlas::material_maps`), uploaded for the shader packs
/// loaded from now on; a map too large for this GPU is left out.
pub fn set_shared_material_maps(&mut self, maps: &[Option<crate::paged_maps::PagedMap>; 2]) {
let limit = self.device.limits().max_texture_dimension_2d;
let sizes: Vec<String> = (maps.iter().zip(["normals", "specular"]))
.filter_map(|(m, name)| {
m.as_ref()
.map(|m| format!("{name} {}x{}", m.width, m.height))
})
.collect();
if !sizes.is_empty() {
let bytes: usize = maps.iter().flatten().map(|m| m.pixels.len()).sum();
log::info!(
"shared atlas LabPBR maps: {} ({:.1} MiB)",
sizes.join(", "),
bytes as f64 / f64::from(1 << 20)
);
}
self.shared_material_maps = maps.each_ref().map(|map| {
let map = map.as_ref().filter(|m| m.width.max(m.height) <= limit)?;
Some(self.device.create_texture_with_data(
&self.queue,
&wgpu::TextureDescriptor {
label: Some("Shared atlas material map"),
size: wgpu::Extent3d {
width: map.width,
height: map.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&map.pixels,
))
});
}
/// 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) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
@@ -5151,6 +5274,15 @@ impl Renderer {
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
}
/// The resource packs' `core/sky` and `core/stars` (`HudAtlas::sky_shader`,
/// `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>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.frame_format, disc, stars);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -5901,6 +6033,7 @@ impl Renderer {
solid,
solid_slots,
packed,
packed_far: packed && mesh.packed_far,
sides: if mesh.sides.quads() == solid {
mesh.sides
} else {
@@ -6402,7 +6535,9 @@ impl Renderer {
}
self.packed_quads = want;
packed::pack_quads(want);
if !want && self.sections.values().any(|s| s.packed) {
let laid_out = self.sections.values().any(|s| s.packed)
|| self.lods.values().any(|t| t.drawn().packed);
if !want && laid_out {
self.sections_dropped = true;
self.clear_sections(self.layout);
}
@@ -6722,6 +6857,21 @@ impl Renderer {
hdr.look = on;
}
}
/// Whether this renderer's frame is the one a renderer made now would draw in, with HDR and
/// Smooth Gradients (`wide_frame`) and the pack picked (`linear_pack`, `hdr_float`, as
/// `Renderer::new` takes them). Under `HORIZON_HDR_FRAME=float` the game makes the renderer
/// again when it isn't (docs/design/hdr-linear-light.md, "When it changes").
pub fn frame_follows(&self, wide_frame: bool, linear_pack: bool, hdr_float: bool) -> bool {
let (hdr_offered, wide_ok, linear_ok) = self.frame_device;
self.frame_format
== frame_format(
wide_frame,
hdr_offered,
wide_ok,
self.config.format,
(linear_pack, hdr_float, linear_ok),
)
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
@@ -6959,7 +7109,7 @@ impl Renderer {
dimension: i32,
post: std::result::Result<Option<pack_post::PackPost>, pack_post::Refused>,
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
let post = match post {
let mut post = match post {
Ok(post) => post,
Err(e) => {
self.pack_post = Some((dimension, None));
@@ -6968,6 +7118,12 @@ impl Renderer {
return Err(e);
}
};
if let Some(post) = post.as_mut() {
post.set_paged_maps(
(self.shared_material_maps.each_ref())
.map(|t| t.as_ref().map(|t| t.create_view(&Default::default()))),
);
}
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
@@ -7996,6 +8152,41 @@ 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");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
let hdr_written = written_lane(
self.hdr.as_ref().is_some_and(|h| h.layered()),
self.frame_format,
self.shader_pack_kind().is_some(),
!frame.title,
);
if self.hdr_written != hdr_written {
log::info!(
"Horizon HDR frame: {}",
if hdr_written {
"written light"
} else {
"marked"
}
);
}
self.hdr_written = hdr_written;
let (hdr_settings, hdr_shares) = self
.hdr
.as_ref()
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
self.world_post
.light_representation(self.hdr_written.then(|| {
(
hdr_settings,
hdr_shares,
self.hdr.as_ref().unwrap().windows(),
)
}));
let jitter = self.world_post.jitter(
[self.config.width, self.config.height],
taa,
@@ -8150,8 +8341,10 @@ impl Renderer {
},
// Only HDR output reads the solid terrain's lighting alpha.
if self.hdr.is_some() { 1.0 } else { 0.0 },
0.0,
f32::from(u8::from(self.hdr_written)),
],
hdr_settings,
hdr_shares,
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -8664,12 +8857,23 @@ impl Renderer {
.push_in(group, v.distance, s.page, start, len, [0; 3]);
}
if let Some(c) = &mut cull {
c.drawn(i, v.slot, indices, cull::SECTION_BOX);
c.drawn(
i,
v.slot,
indices,
cull::section_box(self.sections.bounds(v.slot)),
);
}
} else if let Some(c) = &mut cull {
// One test sees the section's draws in both passes.
let total: usize = runs.iter().map(|r| r.2).sum();
if let Some(test) = c.deferred(i, v.slot, total, indices, cull::SECTION_BOX) {
if let Some(test) = c.deferred(
i,
v.slot,
total,
indices,
cull::section_box(self.sections.bounds(v.slot)),
) {
for &(group, start, len) in runs {
if group == 0 {
phase2_opaque += len;
@@ -9076,6 +9280,39 @@ impl Renderer {
self.uploads.buffer(&mut encoder, core.uniform(), 0, &data);
}
}
// A pack's core/sky and core/stars: the disc with the sky colour, the stars with their
// brightness, both turned with the camera only (SkyRenderer's model view).
if let Some(core) = &self.core_sky
&& !frame.title
{
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
}
.bytes(size, frame.post_effects.menu_blur_radius as i32);
let [sky_end, clouds_end, ..] = globals.fog_ends;
let fog = [fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end];
let view = views.primary();
// Far enough for the disc's rim, 512 out: the sky draws without a depth test.
let fov = (2.0 * view.tan_half_fov.atan()).to_degrees();
let projection = jitter * world_projection(fov, view.aspect, 2048.0) * pose;
let [r, g, b, ..] = frame.sky.sky_color;
let sky =
core_sky::uniforms(block, projection, view.view, [r, g, b, 1.0], fog_color, fog);
let stars = frame.stars.map(|(rotation, brightness)| {
let b = brightness.clamp(0.0, 1.0);
core_sky::uniforms(
block,
projection,
view.view * rotation,
[b; 4],
fog_color,
fog,
)
});
core.prepare(&mut self.uploads, &mut encoder, &sky, stars.as_deref());
}
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
@@ -9109,12 +9346,6 @@ impl Renderer {
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
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");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -9421,6 +9652,7 @@ impl Renderer {
&mut encoder,
jittered * rotation,
brightness,
&globals,
);
}
self.custom_sky.prepare(
@@ -9428,6 +9660,7 @@ impl Renderer {
&mut encoder,
jittered,
&self.custom_sky_draws,
&globals,
);
let celestial_vertices = frame.celestial.len().min(2048 * 4);
if frame.end_sky && !frame.title {
@@ -9982,6 +10215,12 @@ impl Renderer {
view_proj: mirror_view_projection(view_proj, plane).to_cols_array_2d(),
inverse_view,
fog_ends,
hdr_light: [
globals.hdr_light[0],
globals.hdr_light[1],
globals.hdr_light[2],
0.0,
],
..globals
};
self.uploads.buffer(
@@ -10165,8 +10404,16 @@ impl Renderer {
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
match self.core_sky.as_ref().filter(|c| c.draws_disc()) {
Some(core) => {
core.draw_disc(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
None => {
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
}
}
if frame.sky.sunrise_color[3] > 0.001 {
pass.set_pipeline(&self.sunrise_pipeline);
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
@@ -10217,7 +10464,10 @@ impl Renderer {
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
self.stars.draw(&mut pass);
match self.core_sky.as_ref().filter(|c| c.draws_stars()) {
Some(core) => core.draw_stars(&mut pass),
None => self.stars.draw(&mut pass),
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.dark_disc && !pack_draws_sky {
@@ -10561,7 +10811,7 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
}
@@ -10600,7 +10850,7 @@ impl Renderer {
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
// A glowing item in the world (Item sheet quads), as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(
@@ -10655,7 +10905,8 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if crumbling_quads > 0 && !oit_on {
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
if crumbling_quads > 0 && (!oit_on || self.hdr_written) {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -10684,7 +10935,7 @@ impl Renderer {
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
pass.set_pipeline(&self.hdr_mark_lightning_pipeline);
self.lightning.draw(&mut pass, &frame.lightning);
}
@@ -10724,7 +10975,7 @@ impl Renderer {
pass.draw_indexed(first * 6..end * 6, (world_quads * 4) as i32, 0..1);
first = end;
}
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
// The dragon's eyes and death rays, marked as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
@@ -10831,18 +11082,20 @@ impl Renderer {
}
};
quads(pass, &f.text[s], text_quads, entity_quads);
quads(
pass,
&f.crumbling[s],
crumbling_quads,
entity_quads + text_quads,
);
quads(
pass,
&f.crumbling[s],
translucent_crumbling_quads,
entity_quads + text_quads + crumbling_quads,
);
if !self.hdr_written {
quads(
pass,
&f.crumbling[s],
crumbling_quads,
entity_quads + text_quads,
);
quads(
pass,
&f.crumbling[s],
translucent_crumbling_quads,
entity_quads + text_quads + crumbling_quads,
);
}
quads(
pass,
&f.swirl[s],
@@ -10953,7 +11206,7 @@ impl Renderer {
// HDR output's mark on what shines in the stages, as the classic
// pass marks it after drawing it: eyes, the dragon's eyes and rays,
// firework sparks and lightning.
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11022,7 +11275,7 @@ impl Renderer {
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
if self.hdr.is_some() {
if self.hdr.is_some() && !self.hdr_written {
// Fireworks: their sparks are translucent-layer particles.
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
pass.draw_indexed(
@@ -11062,7 +11315,7 @@ impl Renderer {
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if translucent_crumbling_quads > 0 && !oit_on {
if translucent_crumbling_quads > 0 && (!oit_on || self.hdr_written) {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -11659,7 +11912,9 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..overlay.hand_quads * 6, overlay.first_hand_vertex, 0..1);
drop(pass);
if let Some(hdr) = &self.hdr {
if let Some(hdr) = &self.hdr
&& !self.hdr_written
{
hdr.mark_hand(encoder, timing.as_mut());
// On the layered frame the hand's marks go to the frame, before they become
// light with the world's.
@@ -11708,6 +11963,7 @@ impl Renderer {
// The layered frame's marks become light: what draws from here draws on light, and
// nothing is lifted after.
if layered
&& !self.hdr_written
&& !frame.title
&& let Some(hdr) = &self.hdr
{
@@ -11779,6 +12035,13 @@ impl Renderer {
timing.as_mut(),
);
}
// The layered frame's light eases into the peak, before the camera overlays draw on it.
if layered
&& !frame.title
&& let Some(hdr) = &self.hdr
{
hdr.tonemap(encoder, timing.as_mut());
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
@@ -12498,7 +12761,7 @@ struct Overlay {
hand_view_proj: Mat4,
}
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
pub(crate) fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, NEAR_PLANE)
}
@@ -12666,6 +12929,28 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn written_light_requires_the_entire_default_layered_world_lane() {
use super::{LINEAR_FRAME, WIDE_FRAME, written_lane};
assert!(written_lane(true, LINEAR_FRAME, false, true));
for bits in 0..8 {
let layered = bits & 1 != 0;
let pack = bits & 2 != 0;
let world = bits & 4 != 0;
assert_eq!(
written_lane(layered, LINEAR_FRAME, pack, world),
layered && !pack && world
);
assert!(!written_lane(layered, WIDE_FRAME, pack, world));
assert!(!written_lane(
layered,
wgpu::TextureFormat::Bgra8Unorm,
pack,
world,
));
}
}
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
@@ -13389,12 +13674,21 @@ mod tests {
for path in files {
let text = std::fs::read_to_string(&path).unwrap();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "oit.wgsl" || name == "gpu_occlusion.wgsl" {
if name == "oit.wgsl" || name == "gpu_occlusion.wgsl" || name == "hdr_light.wgsl" {
// The shared maths: it needs the file it is appended to. GPU culling's
// occlusion test is appended to gpu_culling.wgsl, on Vulkan alone.
continue;
} else if name == "terrain.wgsl"
|| name == "entity.wgsl"
|| name == "stars.wgsl"
|| name == "custom_sky.wgsl"
{
let base = include_str!("hdr_light.wgsl").to_owned() + &text;
sources.push((path, crate::overlay::wgsl_constants() + &base));
} else if name == "hdr.wgsl" {
sources.push((path, super::hdr::shader()));
} else if name == "oit_world.wgsl" {
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
let base = super::terrain_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_features.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
@@ -13403,7 +13697,7 @@ mod tests {
sources.push((path, static_entity_shader()));
} else if name == "visbuffer.wgsl" {
// Appended to terrain.wgsl (gfx/visbuffer.rs).
sources.push((path, include_str!("terrain.wgsl").to_owned() + &text));
sources.push((path, super::terrain_shader() + &text));
} else if name == "oit_border.wgsl" {
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
@@ -13414,6 +13708,26 @@ mod tests {
sources.push((path, text));
}
}
// The default override specializes written light away. Translate its active form too.
let written: Vec<_> = sources
.iter()
.filter(|(path, _)| {
matches!(
path.file_name().and_then(|n| n.to_str()),
Some("terrain.wgsl" | "entity.wgsl" | "stars.wgsl" | "custom_sky.wgsl")
)
})
.map(|(path, source)| {
(
path.clone(),
source.replace(
"override HDR_WRITE_LIGHT: bool = false;",
"override HDR_WRITE_LIGHT: bool = true;",
),
)
})
.collect();
sources.extend(written);
let mut glsl_checked = 0;
// Shaders the OpenGL backend runs must translate: skipping the ones
// that fail hides exactly the bug where a shader GL needs cannot
@@ -13572,7 +13886,7 @@ mod tests {
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
// off on every backend: they must still translate (the other test translates them
// with the constants' defaults).
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
let module = naga::front::wgsl::parse_str(&super::terrain_shader()).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -13718,6 +14032,7 @@ mod tests {
translucent_at: (0, u64::from(solid) * 4),
solid,
solid_slots: u64::from(solid) * 4,
packed_far: false,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
@@ -14073,7 +14388,7 @@ mod tests {
#[test]
fn distant_tiles_repeat_their_sprite_under_the_clamped_sampler() {
// terrain.wgsl's tile_uv, evaluated by naga as a constant expression.
let source = include_str!("terrain.wgsl");
let source = super::terrain_shader();
let body = source.split("fn tile_uv(").nth(1).unwrap();
let body = body.split("return ").nth(1).unwrap();
let expr = &body[..body.find(';').unwrap()];
+12 -1
View File
@@ -879,6 +879,7 @@ mod tests {
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1047,7 +1048,15 @@ mod tests {
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let mut flat = flat::Flat::new(
&device,
&terrain_layout,
4 * GRAIN,
0,
false,
pipeline,
solid,
);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -1083,6 +1092,7 @@ mod tests {
solid: 64,
solid_slots: 64 * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1310,6 +1320,7 @@ mod tests {
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
packed_far: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -67,6 +67,7 @@ fn copy(mesh: &SectionMesh) -> SectionMesh {
sides: mesh.sides,
translucent: mesh.translucent.clone(),
packed: mesh.packed.clone(),
packed_far: mesh.packed_far,
solid_tints: Vec::new(),
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
@@ -0,0 +1,436 @@
//! A resource pack's `core/sky` and `core/stars` shaders: the overworld's sky disc and stars drawn
//! with the pack's GLSL (`SkyRenderer.renderSkyDisc`, `renderStars`), as a pack that adds a haze
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
pub(crate) const GLUE: Glue = Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// `SkyRenderer.buildSkyDisc(16)`: a fan around the point 16 above the camera, out to 512 every
/// 45 degrees, as a triangle list.
pub(crate) fn disc_triangles() -> Vec<[f32; 3]> {
let rim: Vec<[f32; 3]> = (-180..=180)
.step_by(45)
.map(|i: i32| {
let a = (i as f32).to_radians();
[512.0 * a.cos(), 16.0, 512.0 * a.sin()]
})
.collect();
rim.windows(2)
.flat_map(|edge| [[0.0, 16.0, 0.0], edge[0], edge[1]])
.collect()
}
/// One of the sky's pack pipelines with its vertices and uniforms.
struct Part {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
count: u32,
}
impl Part {
fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
program: &CoreProgram,
name: &str,
blend: Option<wgpu::BlendState>,
triangles: &[[f32; 3]],
) -> Option<Self> {
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = |stage, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&format!("Resource pack {name} ({stage})")),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("vertex", &program.vertex);
let fragment = module("fragment", &program.fragment);
// Vanilla's SKY and STARS pipelines have no samplers: a pack that reads one is not used.
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(name),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(name),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(name),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(name),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
})],
},
primitive: wgpu::PrimitiveState::default(),
// The sky is drawn first, behind everything: no depth test, no depth write.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's {name} shader is not used: {e}");
return None;
}
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: bytemuck::cast_slice(triangles),
usage: wgpu::BufferUsages::VERTEX,
});
Some(Self {
pipeline,
group,
uniform,
vertices,
count: triangles.len() as u32,
})
}
fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw(0..self.count, 0..1);
}
}
/// The packs' sky disc and stars pipelines, each present when a pack replaces that shader.
pub(super) struct CoreSky {
disc: Option<Part>,
stars: Option<Part>,
}
impl CoreSky {
/// `None` when neither shader is replaced or neither builds.
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
disc: Option<&CoreProgram>,
stars: Option<&CoreProgram>,
) -> Option<Self> {
let disc = disc.and_then(|program| {
// RenderPipelines.SKY: ColorTargetState.DEFAULT, no blending.
Part::new(device, format, program, "sky", None, &disc_triangles())
});
let stars = stars.and_then(|program| {
// RenderPipelines.STARS: BlendFunction.OVERLAY (src alpha and one; alpha one, zero).
let overlay = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
};
let triangles = super::stars::star_triangles();
Part::new(device, format, program, "stars", Some(overlay), &triangles)
});
(disc.is_some() || stars.is_some()).then_some(Self { disc, stars })
}
pub(super) fn draws_disc(&self) -> bool {
self.disc.is_some()
}
pub(super) fn draws_stars(&self) -> bool {
self.stars.is_some()
}
/// Uploads the disc's and the stars' blocks: `sky` and `stars` from [`uniforms`].
pub(super) fn prepare(
&self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
sky: &[u8],
stars: Option<&[u8]>,
) {
if let Some(disc) = &self.disc {
uploads.buffer(encoder, &disc.uniform, 0, sky);
}
if let (Some(part), Some(data)) = (&self.stars, stars) {
uploads.buffer(encoder, &part.uniform, 0, data);
}
}
/// The disc, in place of the native sky colour. The caller sets its own group 0 again after.
pub(super) fn draw_disc(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(disc) = &self.disc {
disc.draw(pass);
}
}
/// The stars, in place of the native ones. The caller sets its own group 0 again after.
pub(super) fn draw_stars(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(stars) = &self.stars {
stars.draw(pass);
}
}
}
/// The std140 blocks for one sky draw: `Globals`, `Projection` (`projection`, far enough for the
/// disc's 512-block rim), `DynamicTransforms` (`model_view`: the camera's rotation, times the
/// stars' rotation for them; `color`: the sky colour, or the star brightness in every channel)
/// and `Fog` (`fog_color`, `fog`: environmental start and end, render distance start and end,
/// sky end, clouds end).
pub(super) fn uniforms(
globals: [u8; 48],
projection: Mat4,
model_view: Mat4,
color: [f32; 4],
fog_color: [f32; 4],
fog: [f32; 6],
) -> Vec<u8> {
let mut data = vec![0f32; 256];
data[64..80].copy_from_slice(&projection.to_cols_array());
let transforms = 128;
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
data[transforms + 32..transforms + 36].copy_from_slice(&color);
data[192..196].copy_from_slice(&fog_color);
data[196..202].copy_from_slice(&fog);
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
bytes[..48].copy_from_slice(&globals);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
/// `buildSkyDisc(16)`: eight triangles from the point above the camera to the rim, which
/// starts and ends at x -512 (cos 180°) and passes through z -512, x 512 and z 512.
#[test]
fn the_disc_is_vanillas_fan_as_triangles() {
let triangles = disc_triangles();
assert_eq!(triangles.len(), 24);
assert!(triangles.chunks(3).all(|t| t[0] == [0.0, 16.0, 0.0]));
let rim: Vec<[f32; 3]> = triangles.chunks(3).map(|t| t[1]).collect();
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
assert!(near(rim[0], [-512.0, 16.0, 0.0]));
assert!(near(rim[2], [0.0, 16.0, -512.0]));
assert!(near(rim[4], [512.0, 16.0, 0.0]));
assert!(near(rim[6], [0.0, 16.0, 512.0]));
assert!(near(triangles[23], [-512.0, 16.0, 0.0]));
}
/// The prepared resources (`HORIZON_RESOURCES`, or `.local/horizon-client/<version>`), where
/// vanilla's sky shader and its includes are.
fn resources() -> std::path::PathBuf {
std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
})
}
/// Vanilla's `sky.vsh` with a pack's fragment shader that paints `ColorModulator` (the sky
/// colour) where the disc is: looking at the horizon, the top half of the target is the
/// disc, the bottom half stays clear.
#[test]
fn a_pack_sky_shader_paints_the_disc_above_the_horizon() {
let root = resources();
let Ok(vertex) =
std::fs::read_to_string(root.join("assets/minecraft/shaders/core/sky.vsh"))
else {
eprintln!("skipping: no prepared resources at {}", root.display());
return;
};
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
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)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU: skipping");
return;
};
const SIZE: u32 = 64;
let format = wgpu::TextureFormat::Rgba8Unorm;
let sky = CoreSky::new(&device, format, Some(&program), None).expect("the sky pipeline");
let projection = crate::gfx::world_projection(90.0, 1.0, 2048.0);
let data = uniforms(
[0; 48],
projection,
Mat4::IDENTITY,
[0.25, 0.5, 1.0, 1.0],
[0.0; 4],
[1e6; 6],
);
queue.write_buffer(&sky.disc.as_ref().unwrap().uniform, 0, &data);
let target = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let color = target(format, wgpu::TextureUsages::COPY_SRC);
let depth = target(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::empty(),
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
sky.draw_disc(&mut pass);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: usize, y: usize| pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4].to_vec();
// Row 0 is the top: the disc, in the sky colour; the bottom row is below the horizon.
let top = at(32, 4);
assert!(
top.iter()
.zip([64u8, 128, 255, 255])
.all(|(&p, w)| p.abs_diff(w) <= 1),
"{top:?}"
);
assert_eq!(at(32, 60), vec![0, 0, 0, 0]);
}
}
+11 -2
View File
@@ -10,8 +10,8 @@
//! second pass, draws the sections whose box was seen; the others' draws have
//! an instance count of 0.
//!
//! The picture stays the one without culling: the boxes (grown by a block,
//! the farthest a block model reaches out of its cell) are drawn front faces
//! The picture stays the one without culling: the boxes around the mesh's
//! geometry (padded by a block) are drawn front faces
//! only, so a fragment is the box's nearest point on its pixel, and the
//! fragment shader runs only where the depth test passes (forced early), so a
//! section is left out only where all of its fragments would fail the depth
@@ -45,6 +45,15 @@ use std::sync::{
const WORDS: u64 = 5 * 4;
/// A tested section's box, around its origin: its cell grown by a block.
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
/// Mesh bounds already contain `BOUNDS_PAD`; grow them to a block of padding
/// in total, keeping the box's faces off the geometry. Without mesh bounds,
/// keep the section cell grown by a block, as before.
pub fn section_box(bounds: Option<[glam::Vec3; 2]>) -> [[f32; 3]; 2] {
bounds.map_or(SECTION_BOX, |[low, high]| {
let pad = glam::Vec3::splat(1.0 - crate::mesher::SectionMesh::BOUNDS_PAD);
[(low - pad).to_array(), (high + pad).to_array()]
})
}
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
/// count their slots apart from the sections' resident ones.
pub const TILE: u32 = 1 << 31;
@@ -16,8 +16,8 @@ struct Globals {
// `place`): the first pass's first draw, both passes' draw counts (the
// second's in the high 16 bits), the second pass's first draw. No draws:
// phase 1 drew it, and the test only tells whether it is still in sight.
// Then its box's lowest and highest corner around the origin (a section's:
// -1 and 17, `SECTION_BOX`).
// Then its box's lowest and highest corner around the origin: a section's
// padded mesh bounds, or -1 and 17 (`SECTION_BOX`) without mesh bounds.
struct Test {
data: vec4<u32>,
low: vec4<f32>,
@@ -53,8 +53,8 @@ fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f3
);
}
// Each tested section's or far tile's box, grown by a block (block models
// reach one block out of their cell), drawn after phase 1 against its depth
// Each tested section's or far tile's geometry box, padded by a block,
// drawn after phase 1 against its depth
// with back faces culled: a front face fragment that passes the depth test
// is the box's nearest point on that pixel in front of phase 1's depth, so
// the section may show there. A box failing everywhere would have every
@@ -33,6 +33,9 @@ struct Uniform {
brightness: f32,
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_light: [f32; 4],
}
#[repr(C)]
@@ -45,6 +48,8 @@ struct Vertex {
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
written_gain: Option<f32>,
written_debug: bool,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
@@ -136,7 +141,10 @@ impl CustomSky {
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"))
.into(),
),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
@@ -146,7 +154,11 @@ impl CustomSky {
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.map(|&mode| {
.copied()
.map(|mode| (mode, false, false))
.chain((format == super::LINEAR_FRAME).then_some((Blend::Screen, true, false)))
.chain((format == super::LINEAR_FRAME).then_some((Blend::Add, false, true)))
.map(|(mode, written_screen, debug_add)| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
@@ -174,14 +186,31 @@ impl CustomSky {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_custom_sky"),
compilation_options: Default::default(),
entry_point: Some(if debug_add {
"fs_custom_sky_screen_light"
} else {
"fs_custom_sky"
}),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: blend(mode),
color: if written_screen {
wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Constant,
..blend(mode)
}
} else {
blend(mode)
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
@@ -215,6 +244,8 @@ impl CustomSky {
});
Self {
pipelines,
written_gain: None,
written_debug: false,
layout,
sampler,
uniform,
@@ -295,7 +326,20 @@ impl CustomSky {
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
globals: &super::Globals,
) {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
}
});
self.written_debug = self.written_gain.is_some()
&& globals.hdr_settings[3] > 0.5
&& globals.hdr_settings[2] > 0.5;
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
@@ -307,6 +351,9 @@ impl CustomSky {
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
@@ -323,7 +370,35 @@ impl CustomSky {
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
let screen_debug = usize::from(mode) == 5 && self.written_debug;
if screen_debug {
// Greyscale debug is not a uniform RGB gain. Attenuate with the original
// source first, then add its converted light, without changing the factor.
pass.set_pipeline(&self.pipelines[4]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
let index = if screen_debug {
10
} else if usize::from(mode) == 5 && self.written_gain.is_some() {
9
} else {
usize::from(mode)
};
if let Some(gain) = self.written_gain {
let gain = f64::from(gain);
pass.set_blend_constant(wgpu::Color {
r: gain,
g: gain,
b: gain,
a: 1.0,
});
}
pass.set_pipeline(&self.pipelines[index]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
@@ -394,7 +469,10 @@ mod tests {
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
let module = naga::front::wgsl::parse_str(
&(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl")),
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -413,4 +491,243 @@ mod tests {
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
#[test]
fn written_custom_sky_modes_keep_factors_and_scale_only_screen_source() {
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 source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("custom_sky.wgsl")
+ r#"
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> Out {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
return Out(vec4(p * 2.0 - 1.0, 0.0, 1.0), vec2(0.5), vec4(0.0, 0.0, 1.0, 1.0));
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&sky.layout)],
..Default::default()
});
let image = device.create_texture_with_data(
&queue,
&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: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[204, 102, 51, 128],
);
let image = image.create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
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: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false))
.chain([(5, Blend::Screen, true)])
{
let uniform = Uniform {
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
brightness: 0.7,
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, 1.0],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&uniform),
usage: wgpu::BufferUsages::UNIFORM,
});
let color = if debug {
blend(Blend::Add)
} else if mode == Blend::Screen {
wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Constant,
..blend(mode)
}
} else {
blend(mode)
};
let pipeline_for = |entry, color| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("test_vertex"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: Some(wgpu::BlendState {
color,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let pipeline = pipeline_for(
if debug {
"fs_custom_sky_screen_light"
} else {
"fs_custom_sky"
},
color,
);
let attenuate = debug.then(|| pipeline_for("fs_custom_sky", blend(Blend::Burn)));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &sky.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&image),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 2.0,
g: 2.0,
b: 2.0,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
let g = f64::from(gain);
pass.set_blend_constant(wgpu::Color {
r: g,
g,
b: g,
a: 1.0,
});
pass.set_bind_group(0, &group, &[0]);
if let Some(attenuate) = &attenuate {
pass.set_pipeline(attenuate);
pass.draw(0..3, 0..1);
}
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
}
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
target.size(),
);
queue.submit([encoder.finish()]);
readback.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = readback.get_mapped_range(..).unwrap();
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
+ 0.0722 * 0.2f32.powf(2.2))
.powf(1.0 / 2.2);
gain * grey * k + d * (1.0 - t * k)
} else {
match mode {
Blend::Add => d + gain * t * k,
Blend::Subtract => d - gain * t * k,
Blend::Multiply => d * (1.0 + (t - 1.0) * k),
Blend::Dodge => d + d * t * k,
Blend::Burn => d * (1.0 - t * k),
Blend::Screen => gain * t * k + d * (1.0 - t * k),
Blend::Replace => gain * t,
Blend::Overlay => 2.0 * d * (0.5 + (t - 0.5) * k),
Blend::Alpha => gain * t * k + d * (1.0 - k),
}
};
assert!(
(got - expected).abs() < 0.004,
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
}
}
@@ -8,6 +8,10 @@ struct Layer {
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@@ -42,20 +46,34 @@ const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
fn custom_sky_color(in: Out) -> vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
}
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
case REPLACE: { return vec4(color, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(t.rgb, k); }
case ALPHA: { return vec4(color, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(t.rgb * k, 1.0); }
default: { return vec4(color * k, 1.0); }
}
}
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
return custom_sky_color(in);
}
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
}
+17 -2
View File
@@ -106,6 +106,8 @@ pub struct Flat {
phase2_calls: wgpu::Buffer,
phase2_call_capacity: u64,
phase2_call_data: Vec<[u32; 5]>,
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
indirect_execution: bool,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
@@ -365,6 +367,7 @@ impl Flat {
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
indirect_execution: bool,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -489,10 +492,16 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
20,
wgpu::BufferUsages::INDIRECT | storage_usage,
storage_usage
| if indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
),
phase2_call_capacity: 1,
phase2_call_data: Vec::new(),
indirect_execution,
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
@@ -645,6 +654,7 @@ impl Flat {
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase2 = phase2.filter(|_| self.indirect_execution);
let phase1_draws = if phase2.is_some() {
phase1_draws
} else {
@@ -753,7 +763,12 @@ impl Flat {
device,
"Flat section draw calls, phase 2",
self.phase2_call_capacity * 20,
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE
| if self.indirect_execution {
wgpu::BufferUsages::INDIRECT
} else {
wgpu::BufferUsages::empty()
},
);
}
if phase2_calls > 0 {
+232 -25
View File
@@ -72,25 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
/// hand marked it, for `resolve`.
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
/// world and the hand marked it) or `tonemap` found it.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
/// `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
/// camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Reads what `resolve` reads.
tonemap: wgpu::RenderPipeline,
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
@@ -197,6 +201,10 @@ pub struct Hdr {
pub calibration: Option<Calibration>,
}
pub(super) fn shader() -> String {
include_str!("../hdr_light.wgsl").to_owned() + include_str!("../hdr.wgsl")
}
impl Hdr {
pub fn new(
device: &wgpu::Device,
@@ -220,7 +228,7 @@ impl Hdr {
let coverage_debug = coverage && debug == Some("coverage");
// The layered frame's last pass, else today's.
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
let mut source = include_str!("../hdr.wgsl").to_owned();
let mut source = shader();
if coverage_debug {
// The debug view wraps the last pass as it is, so the picture under the tint is
// exactly the one without it.
@@ -329,8 +337,10 @@ impl Hdr {
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
Layered {
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
resolve,
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
params: params(),
@@ -532,11 +542,33 @@ impl Hdr {
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
/// become light in the frame (`fs_resolve`).
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
}
/// On the layered frame, after the post effects and before the camera overlays: the tone
/// curve eases the light into the peak (`fs_tonemap`).
pub fn tonemap(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
}
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
/// target.
fn redraw(
&self,
encoder: &mut wgpu::CommandEncoder,
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
@@ -550,14 +582,7 @@ impl Hdr {
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(
encoder,
&l.resolve.1,
group,
&view,
"HDR marks to light",
timing,
);
Self::draw(encoder, pipeline(l), group, &view, label, timing);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
@@ -811,6 +836,11 @@ impl Hdr {
})
}
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
let p = self.params(world, false);
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
@@ -921,6 +951,99 @@ mod tests {
use super::{Hdr, Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
fn shared_light_matches_the_original_for_every_mark() {
use wgpu::util::DeviceExt;
let Some((device, queue)) = gpu() else {
eprintln!("HDR equivalence GPU unavailable");
return;
};
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
var<private> params: ReferenceParams;
@group(0) @binding(0) var<storage, read_write> errors: array<u32>;
"# + include_str!("hdr_light_reference.txt")
+ r#"
@compute @workgroup_size(64) fn check(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
let mark = f32(i % 256u) / 255.0;
let mode = i / 256u;
let eased = (mode & 1u) != 0u;
let shows = (mode & 2u) != 0u;
params = ReferenceParams(select(1.0, 1000.0 / 203.0, (mode & 4u) != 0u),
select(1.0, 4.0, (mode & 8u) != 0u), select(0.0, 1.0, (mode & 16u) != 0u),
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
let expected = reference_lit(color, mark, shows, eased);
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
let quantized = round(saturate(stored) * 255.0) / 255.0;
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
}
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shared HDR equivalence"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &shader,
entry_point: Some("check"),
compilation_options: Default::default(),
cache: None,
});
let count = 256 * 128;
let output = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &vec![0; count * 4],
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: (count * 4) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: output.as_entire_binding(),
}],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups(count as u32 / 64, 1, 1);
}
encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, (count * 4) as u64);
queue.submit([encoder.finish()]);
readback.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = readback.get_mapped_range(..).unwrap();
for (i, bytes) in data.as_chunks::<4>().0.iter().enumerate() {
assert_eq!(u32::from_le_bytes(*bytes), 0, "case {i}");
}
eprintln!(
"HDR equivalence GPU ran {count} cases and {} half-float boundary cases",
count * 3
);
}
#[test]
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
let instance = crate::gfx::test_gpu::instance();
@@ -1007,14 +1130,18 @@ mod tests {
}
}
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Clears all of `texture` to `color`.
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
pub(super) fn fill(
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
color: [f64; 4],
) {
let view = texture.create_view(&Default::default());
let mut attachment = super::super::hud_coverage::attachment(&view);
let [r, g, b, a] = color;
@@ -1027,7 +1154,7 @@ mod tests {
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
/// for HDR10.
fn texel(
pub(super) fn texel(
device: &wgpu::Device,
queue: &wgpu::Queue,
mut encoder: wgpu::CommandEncoder,
@@ -1072,7 +1199,8 @@ mod tests {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it.
// (`layered` false) or the layered one, whose marks turn into light before it, then
// pass the tone curve.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
@@ -1082,6 +1210,7 @@ mod tests {
if layered {
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.tonemap(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
@@ -1090,7 +1219,8 @@ mod tests {
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
// the tone curve's knee, which the curve leaves as they are.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
@@ -1112,6 +1242,79 @@ mod tests {
}
}
#[test]
fn the_tone_curve_eases_light_into_the_peak() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let headroom = 1000.0 / 203.0;
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
let after =
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
passes(hdr, &mut encoder);
texel(&device, &queue, encoder, &frame)
};
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
// Linear light in and out, in units of paper white.
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
assert_eq!(a, 1.0);
[r, g, b].map(|c| c.powf(2.2))
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
// Up to the knee (60% of the peak) the light stays exactly as stored.
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
// nits (13.7 times paper white), worked out apart from the shader.
for (light, want) in [
(3.5, 3.43791),
(4.0, 3.78351),
(headroom, 4.23556),
(8.0, 4.82043),
(12.0, 4.92462),
(50.0, headroom),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others: the
// light keeps its hue.
let mut last = 0.0;
for i in 0..40 {
let light = 2.5 * 1.08f32.powi(i);
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
last = r;
}
// No headroom (a peak below paper white): a clip at white, channel by channel.
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
// HDR lighting's gain stays past the knee until the curve: the marks become light
// without easing.
hdr.peak = 1000.0;
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
assert!(near(r.powf(2.2), gain), "{r}");
}
#[test]
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
let Some((device, queue)) = gpu() else {
@@ -1193,12 +1396,12 @@ mod tests {
#[test]
fn hdr_shaders_validate() {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
let entity = super::super::entity_shader();
for source in [
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
&super::shader(),
&super::super::terrain_shader(),
include_str!("../hdr_stats.wgsl"),
include_str!("stars.wgsl"),
&(include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl")),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
@@ -1262,3 +1465,7 @@ mod lighting_tests {
assert_eq!(scene_gain(200.0, 480.0), 1.0);
}
}
#[cfg(test)]
#[path = "hdr_written_tests.rs"]
mod written_tests;
@@ -0,0 +1,65 @@
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
// `eased` for the last pass, which eases it into the peak itself; the layered frame leaves that
// to the tone curve (fs_tonemap).
struct ReferenceLit {
light: vec3<f32>,
lifted: bool,
};
fn reference_lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> ReferenceLit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && a >= 0.75 && a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if eased && v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), eased && v > knee);
}
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (params.peak - 1.0) * params.share[tier] * lift;
if eased {
gain = min(gain, params.peak / max(top, 1e-4));
}
light *= max(gain, 1.0);
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if params.debug > 0.5 && params.world > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
if eased {
light = min(light, vec3(params.peak));
}
return ReferenceLit(light, lifted);
}
@@ -0,0 +1,818 @@
use super::{
Hdr, Output,
tests::{gpu, texel},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
const TERRAIN_VERTEX: &str = r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> TerrainWire {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: TerrainOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.local = vec3(p.x, 0.0, p.y);
o.color = vec4(0.8, 0.9, 0.7, select(1.0, 0.5, globals.misc.z > 0.5));
o.scene = vec2(15.0);
o.spherical = globals.misc.x;
o.cylindrical = globals.misc.x;
o.visibility = globals.misc.y;
o.emission = TEST_TIER;
o.emissive_layer = select(0xffffffffu, 1u, TEST_OVERLAY != 0u);
return to_wire(o);
}
"#;
const ENTITY_VERTEX: &str = r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> Out {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: Out;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
o.back_color = o.color;
o.light_color = vec4(0.7, 0.8, 0.9, 1.0);
o.overlay_color = vec4(1.0);
o.scene = 0.8;
o.tier = TEST_TIER;
o.overlay = TEST_OVERLAY;
o.wrap = vec2(0.0, 1.0);
o.proj = vec4(0.4, 0.6, 0.0, 1.0);
o.fog_distance = vec2(globals.misc.x);
return o;
}
"#;
struct Draw<'a> {
device: &'a wgpu::Device,
queue: &'a wgpu::Queue,
terrain: bool,
shader: wgpu::ShaderModule,
layout: wgpu::PipelineLayout,
bindings: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
}
impl<'a> Draw<'a> {
fn render_oit(
&self,
hdr: &mut Hdr,
kind: &str,
tier: u32,
overlay: u32,
written: bool,
) -> [f32; 4] {
use super::super::oit::{Stage, stage_targets};
let source = if self.terrain {
super::super::terrain_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")
+ TERRAIN_VERTEX
} else {
super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl")
+ include_str!("../oit_clouds.wgsl")
+ ENTITY_VERTEX
}
.replace(
"o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0)",
"o.clip = vec4(p * 2.0 - 1.0, 0.5, 1.0)",
);
let shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual OIT fragments"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let screen_shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
(super::super::terrain_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")
+ TERRAIN_VERTEX)
.into(),
),
});
let make = |format| {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let bounds = make(F::Rgba32Float);
let depth = make(F::Depth32Float);
let coeff = [make(F::Rgba16Float), make(F::Rgba16Float)];
let accumulated = make(F::Rgba16Float);
let uniform = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[0.0_f32, 1.0, 0.0, 0.0]),
usage: wgpu::BufferUsages::UNIFORM,
});
let frame = hdr.frame(self.device, [1, 1]);
let view = frame.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(&Default::default());
super::tests::fill(&mut encoder, &frame, [2.5, 1.8, 0.9, 1.0]);
let constants = [
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
("TEST_TIER", f64::from(tier)),
("TEST_OVERLAY", f64::from(overlay)),
];
for stage in 0..4 {
let textures: Vec<_> = match stage {
0 => vec![],
1 => vec![&bounds],
2 => vec![&bounds, &coeff[0], &coeff[1]],
_ => vec![&bounds, &coeff[0], &coeff[1], &accumulated],
};
let mut entries = vec![wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}];
entries.extend(
textures
.iter()
.enumerate()
.map(|(i, _)| wgpu::BindGroupLayoutEntry {
binding: i as u32 + 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}),
);
let bindings = self
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let mut resources = vec![wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}];
resources.extend(
textures
.iter()
.enumerate()
.map(|(i, t)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(t),
}),
);
let group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &bindings,
entries: &resources,
});
let layout = self
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&self.bindings), None, Some(&bindings)],
..Default::default()
});
let suffix = ["bounds", "transmittance", "accumulate", "composite"][stage];
let entry = if stage == 3 {
"fs_oit_composite".to_owned()
} else if self.terrain {
format!("fs_oit_{suffix}")
} else {
format!("fs_oit_{kind}_{suffix}")
};
let targets = if stage == 3 {
vec![Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})]
} else {
stage_targets([Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate][stage])
};
let shader = if stage == 3 { &screen_shader } else { &shader };
let pipeline = self
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&entry),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("test_vertex"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers: &[],
},
primitive: Default::default(),
depth_stencil: (stage == 3).then_some(wgpu::DepthStencilState {
format: F::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some(&entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &targets,
}),
multiview_mask: None,
cache: None,
});
let outputs = match stage {
0 => vec![&bounds],
1 => vec![&coeff[0], &coeff[1]],
2 => vec![&accumulated],
_ => vec![&view],
};
let attachments: Vec<_> = outputs
.iter()
.map(|v| {
Some(wgpu::RenderPassColorAttachment {
view: v,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: if stage == 3 {
wgpu::LoadOp::Load
} else {
wgpu::LoadOp::Clear(if stage == 0 {
wgpu::Color {
r: -(f32::MAX as f64),
g: 0.0,
b: 0.0,
a: 0.0,
}
} else {
wgpu::Color::TRANSPARENT
})
},
store: wgpu::StoreOp::Store,
},
})
})
.collect();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &attachments,
depth_stencil_attachment: (stage == 3).then_some(
wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
},
),
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(2, &group, &[]);
pass.draw(0..3, 0..1);
}
texel(self.device, self.queue, encoder, &frame)
}
fn new(
device: &'a wgpu::Device,
queue: &'a wgpu::Queue,
terrain: bool,
globals: &super::super::Globals,
alpha: u8,
) -> Self {
let source = if terrain {
super::super::terrain_shader() + TERRAIN_VERTEX
} else {
super::super::entity_shader() + ENTITY_VERTEX
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual HDR draw shader"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let mut entries = vec![];
for binding in 0..if terrain { 13 } else { 8 } {
let sampler = if terrain {
binding == 2 || binding == 4
} else {
binding == 2 || binding == 4 || binding == 6
};
let ty = if binding == 0 {
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
}
} else if sampler {
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering)
} else {
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
multisampled: false,
view_dimension: if terrain && binding != 3 && binding != 12 {
wgpu::TextureViewDimension::D2Array
} else {
wgpu::TextureViewDimension::D2
},
}
};
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty,
count: None,
});
}
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &entries,
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&bindings)],
..Default::default()
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let texture = |layers, data: &[u8]| {
device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: F::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
data,
)
};
let array = texture(2, &[200, 100, 50, alpha, 240, 210, 80, 128]);
let array = array.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let image = texture(1, &[200, 100, 50, alpha]).create_view(&Default::default());
let threshold = texture(1, &[0, 0, 0, 0]).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let resources: Vec<_> = entries
.iter()
.map(|entry| wgpu::BindGroupEntry {
binding: entry.binding,
resource: match entry.ty {
wgpu::BindingType::Buffer { .. } => uniform.as_entire_binding(),
wgpu::BindingType::Sampler(_) => wgpu::BindingResource::Sampler(&sampler),
wgpu::BindingType::Texture {
view_dimension: wgpu::TextureViewDimension::D2Array,
..
} => wgpu::BindingResource::TextureView(&array),
_ => wgpu::BindingResource::TextureView(if entry.binding == 12 {
&threshold
} else {
&image
}),
},
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &bindings,
entries: &resources,
});
Self {
device,
queue,
terrain,
shader,
layout,
bindings,
group,
}
}
fn render(
&self,
hdr: &mut Hdr,
written: bool,
entry: &str,
tier: u32,
overlay: u32,
background: [f64; 4],
) -> [f32; 4] {
let constants = [
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
("TEST_TIER", f64::from(tier)),
("TEST_OVERLAY", f64::from(overlay)),
];
let pipeline = |entry, mark| {
self.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&self.layout),
vertex: wgpu::VertexState {
module: &self.shader,
entry_point: Some("test_vertex"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &self.shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
}),
write_mask: if mark {
wgpu::ColorWrites::ALPHA
} else {
wgpu::ColorWrites::ALL
},
})],
}),
multiview_mask: None,
cache: None,
})
};
let color = pipeline(entry, false);
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
pipeline(
if overlay == 4 {
"fs_hdr_cloud_mark"
} else {
"fs_hdr_mark"
},
true,
)
});
let frame = hdr.frame(self.device, [1, 1]);
let view = frame.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(&Default::default());
{
let [r, g, b, a] = background;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&color);
pass.draw(0..3, 0..1);
if let Some(mark) = &mark {
pass.set_pipeline(mark);
pass.draw(0..3, 0..1);
}
}
if !written {
hdr.begin(self.queue, true);
hdr.resolve(&mut encoder, None);
}
texel(self.device, self.queue, encoder, &frame)
}
}
#[test]
fn actual_opaque_writers_match_marks_then_resolve() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR draw GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
let mut count = 0;
for fog in [0.0, 0.5, 1.0] {
for visibility in [1.0, 0.4] {
let globals = super::super::Globals {
fog_color: [0.2, 0.3, 0.4, 1.0],
fog: [0.0, 1.0, 0.0, 1.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [fog, visibility, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
let draw = Draw::new(&device, &queue, terrain, &globals, 255);
for tier in 0..=4 {
for overlay in if terrain {
vec![0, 1]
} else {
vec![0, 6, 9, 10, 38]
} {
let entries = if terrain {
vec!["fs_opaque", "fs_solid"]
} else {
vec!["fs_entity"]
};
for entry in entries {
let marked =
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let written =
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
// The marked path stores half floats before conversion; the written
// one stores after it. Allow two half-float ULPs in gamma light.
for i in 0..3 {
assert!(
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
"{terrain} {entry} tier {tier} overlay {overlay} fog {fog} visibility {visibility}: {marked:?} {written:?}"
);
}
assert_eq!(written[3], 1.0);
count += 1;
}
}
}
}
}
}
eprintln!("HDR actual draw GPU ran {count} opaque comparisons");
}
#[test]
fn actual_translucent_writers_blend_their_own_light() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR blend GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [0.0, 1.0, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
// `fs_translucent_mark` skips: written, it still gets its own light.
for (terrain, entry, overlay, tier, tint_alpha, texel_alpha) in [
(true, "fs_translucent", 0, 0, 1.0, 180),
(true, "fs_translucent", 0, 0, 0.5, 180),
(true, "fs_translucent", 0, 0, 1.0, 100),
(false, "fs_entity", 0, 3, 1.0, 180),
(false, "fs_entity", 4, 0, 1.0, 180),
] {
let mut globals = globals;
globals.misc[2] = if tint_alpha < 1.0 { 1.0 } else { 0.0 };
let draw = Draw::new(&device, &queue, terrain, &globals, texel_alpha);
let src = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
let dst = [2.5, 1.8, 0.9, 1.0];
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
if terrain {
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
// vertex coverage is halved.
let gain = (1.0 + (settings[1] - 1.0) * (1.0 - 194.0 / 255.0) / 0.24).powf(1.0 / 2.2);
let expected = 200.0 / 255.0 * 0.8 * gain * alpha;
assert!(
(src[0] - expected).abs() < 0.002,
"coverage {tint_alpha}: {src:?}, expected {expected}"
);
}
for i in 0..3 {
let want = src[i] + dst[i] as f32 * (1.0 - alpha);
assert!(
(got[i] - want).abs() < 0.003,
"{entry} {overlay}: {got:?} expected {want}"
);
}
assert!(got[0] > 1.0);
}
eprintln!("HDR actual blend GPU ran terrain, particle and cloud equations");
}
#[test]
fn unmarked_entity_users_keep_their_source_gain() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 255);
for (entry, overlay) in [
("fs_entity_unmarked", 0),
("fs_entity_shadow", 0),
("fs_energy_swirl", 0),
("fs_entity", 11),
("fs_entity", 12),
] {
let raw = draw.render(&mut hdr, true, entry, 0, overlay, [0.0; 4]);
let mut hdr_off = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
hdr_off.look = false;
let legacy = draw.render(&mut hdr_off, false, entry, 0, overlay, [0.0; 4]);
for i in 0..3 {
assert!(
(raw[i] - legacy[i]).abs() < 0.001,
"{entry} {overlay}: {raw:?} {legacy:?}"
);
}
}
}
#[test]
fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 16);
let background = [0.0, 0.0, 0.0, 1.0];
let marked = draw.render(&mut hdr, false, "fs_celestial", 4, 0, background);
let written = draw.render(&mut hdr, true, "fs_celestial", 4, 0, background);
// The old mark draw discards this texel; source alpha still contributes its colour.
let red = (200.0 / 255.0) * 0.8 * 0.7 * (16.0 / 255.0);
assert!(
(written[0] - red).abs() < 0.0001,
"{written:?}: expected {red}"
);
for i in 0..3 {
assert!(
(written[i] - marked[i]).abs() < 0.0001,
"{written:?} {marked:?}"
);
}
}
fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas: &[u8]) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("OIT GPU unavailable");
return;
};
if !adapter
.features()
.contains(wgpu::Features::FLOAT32_BLENDABLE)
{
eprintln!("OIT unsupported on {:?}", adapter.get_info());
return;
}
eprintln!(
"OIT GPU {:?}: {kind}, alphas {alphas:?}",
adapter.get_info()
);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: wgpu::Features::FLOAT32_BLENDABLE,
..Default::default()
}))
.unwrap();
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [0.0, 1.0, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for &alpha in alphas {
let draw = Draw::new(&device, &queue, terrain, &globals, alpha);
let got = draw.render_oit(&mut hdr, kind, tier, overlay, true);
let unconverted = draw.render_oit(&mut hdr, kind, tier, overlay, false);
let entry = if terrain {
"fs_translucent"
} else if kind == "lightning" {
"fs_celestial"
} else {
"fs_entity"
};
let mut expected = draw.render(
&mut hdr,
true,
entry,
tier,
overlay,
if kind == "lightning" {
[0.0; 4]
} else {
[2.5, 1.8, 0.9, 1.0]
},
);
if kind == "lightning" {
for (c, background) in expected[..3].iter_mut().zip([2.5, 1.8, 0.9]) {
*c += background;
}
}
for i in 0..3 {
assert!(
(got[i] - expected[i]).abs() < 0.006,
"{kind} alpha {alpha}: {got:?} expected {expected:?}"
);
}
assert_eq!(got[3], 1.0);
assert!(got[0] > 1.0, "OIT clipped light: {got:?}");
assert!(
(got[0] - unconverted[0]).abs() > 0.01,
"test must distinguish missing conversion: {got:?} {unconverted:?}"
);
}
}
#[test]
fn actual_oit_terrain_writes_its_own_light_including_clear_glass() {
check_oit_writer(true, "terrain", 0, 0, &[180, 100]);
}
#[test]
fn actual_oit_particle_writes_its_own_light() {
check_oit_writer(false, "entity", 0, 3, &[180]);
}
#[test]
fn actual_oit_cloud_writes_its_own_light() {
check_oit_writer(false, "cloud", 4, 0, &[180]);
}
#[test]
fn actual_oit_additive_adds_converted_light_without_coverage() {
check_oit_writer(false, "lightning", 0, 4, &[180]);
}
@@ -446,7 +446,7 @@ mod tests {
#[test]
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
let mut module = naga::front::wgsl::parse_str(&crate::gfx::terrain_shader()).unwrap();
wrap(
&mut module,
"fs_debug_line",
@@ -98,8 +98,24 @@ fn interfaces(source: &str) -> Vec<Interface> {
/// The shaders as the renderer assembles them, by the `.wgsl` files each is made of.
fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
let entity = super::entity_shader();
vec![
(
"HDR",
vec!["hdr.wgsl", "hdr_light.wgsl"],
super::hdr::shader(),
),
("terrain", vec!["terrain.wgsl"], super::terrain_shader()),
(
"stars",
vec!["stars.wgsl"],
include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl"),
),
(
"custom sky",
vec!["custom_sky.wgsl"],
include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"),
),
("entity", vec!["entity.wgsl"], entity.clone()),
(
"GPU culling occlusion test",
@@ -124,14 +140,14 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
(
"improved transparency world",
vec!["terrain.wgsl", "oit.wgsl", "oit_world.wgsl"],
include_str!("../terrain.wgsl").to_owned()
super::terrain_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"),
),
(
"visibility buffer",
vec!["terrain.wgsl", "visbuffer.wgsl"],
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
(
"improved transparency world border",
@@ -309,7 +309,8 @@ pub struct NativeMaterial {
/// every name it declares prefixed.
fn vanilla_terrain() -> String {
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
&(include_str!("../hdr_light.wgsl").to_owned()
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
PREFIX,
)
}
@@ -73,10 +73,15 @@ pub struct WorldDrawn<'a> {
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API 371).
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player,
/// `(2 << 32) | id` for a dropped item, `(3 << 32) | id` for another entity (boat,
/// minecart, armor stand, item frame, painting, primed TNT, falling block) and
/// `(4 << 32) | id` for an item frame's held item (their entity ids), `(5 << 32) | p` for a
/// block entity (`p`: its position, x and z in 11 bits each, y in 10), `(6 << 32) | p`
/// for a block a piston moves (`p`: the cell it moves to), `7 << 32` for the player's own
/// model, 0 for anything else.
/// A run lasts until the next. Unlike `spans`, whose ids name a type, these follow one
/// entity from frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
@@ -512,7 +512,8 @@ fn shader_source(
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
&(include_str!("../hdr_light.wgsl").to_owned()
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
PREFIX
),
super::native_post::line_helpers(api)
@@ -30,6 +30,7 @@ pub struct NativeSky {
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
fn vanilla_sky() -> String {
let text = [
include_str!("../hdr_light.wgsl"),
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
+22 -5
View File
@@ -192,7 +192,7 @@ impl Oit {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned()
(super::terrain_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
@@ -253,7 +253,14 @@ impl Oit {
..Default::default()
})
};
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let constants = [
("SPRITE_ARRAYS", sprites[0]),
("SPRITE_SPLIT", sprites[1]),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
),
];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
@@ -464,7 +471,10 @@ impl Oit {
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants[2..],
..Default::default()
},
targets: &stage_targets(stage),
}),
multiview_mask: None,
@@ -488,7 +498,14 @@ impl Oit {
Stage::Accumulate,
),
];
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
let features = Features::new(
device,
&layouts,
features[0],
features[1],
features[2],
format,
);
Self {
uniform,
layouts,
@@ -912,7 +929,7 @@ mod tests {
#[test]
fn the_terrain_shader_validates() {
validate(
&(include_str!("../terrain.wgsl").to_owned()
&(crate::gfx::terrain_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
@@ -55,6 +55,7 @@ impl Features {
entity: &wgpu::BindGroupLayout,
weather: &wgpu::BindGroupLayout,
border: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
) -> Self {
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency features"),
@@ -113,6 +114,15 @@ impl Features {
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
..Default::default()
});
let fragment = if kind.label == "text" && stage == Stage::Accumulate {
"fs_oit_text_accumulate".to_owned()
} else {
format!("fs_oit_{}_{suffix}", kind.name)
};
let constants = [(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("OIT {} {suffix}", kind.label)),
layout: Some(&layout),
@@ -136,8 +146,15 @@ impl Features {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
compilation_options: Default::default(),
entry_point: Some(&fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: if kind.name == "border" {
&[]
} else {
&constants
},
..Default::default()
},
targets: &stage_targets(stage),
}),
multiview_mask: None,
@@ -757,6 +757,7 @@ impl PackAtlas {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
}
@@ -127,6 +127,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}),
item_id_declared: false,
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
paged_maps: true,
}
}
@@ -190,6 +192,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
@@ -82,6 +82,9 @@ pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
_ => rgba,
};
match (f.kind, f.bits) {
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
(Kind::Uint, 10) => T::Rgb10a2Uint,
(Kind::Snorm, 16) => pick(T::R16Snorm, T::Rg16Snorm, T::Rgba16Snorm),
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
@@ -52,6 +52,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
@@ -0,0 +1,207 @@
//! Paged maps (`paged_maps.rs`) through the translator's `hz_pagedTexture` and the pass's
//! binding: a pack reads the map's texels at the colour's coordinates.
use super::*;
use wgpu::util::DeviceExt;
/// The atlas the fixture's coordinates are in, and the one mapped texture's place.
const ATLAS: (u32, u32) = (64, 32);
const SPRITE: [u32; 4] = [20, 4, 20, 20];
/// The fixture's normal map texel at texel (x, y) of its texture.
fn texel(x: u32, y: u32) -> [u8; 4] {
[x as u8 * 10, y as u8 * 10, 7, 255]
}
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn a_pack_reads_paged_maps_at_the_colours_coordinates() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-paged-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
// Each pixel reads one atlas texel's centre: in the texture (on four pages), beside it,
// on a page without maps, and the specular map, which no texture has.
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D normals;
uniform sampler2D specular;
vec2 at(float x, float y) { return (vec2(x, y) + 0.5) / vec2(64.0, 32.0); }
void main() {
int x = int(floor(gl_FragCoord.x));
if (x == 0) gl_FragData[0] = texture2D(normals, at(20.0, 4.0));
else if (x == 1) gl_FragData[0] = texture2D(normals, at(39.0, 23.0));
else if (x == 2) gl_FragData[0] = texture2D(normals, at(31.0, 15.0), 2.0);
else if (x == 3) gl_FragData[0] = texture2DLod(normals, at(32.0, 16.0), 3.0);
else if (x == 4) gl_FragData[0] = texture2D(normals, at(19.0, 4.0));
else if (x == 5) gl_FragData[0] = texture2D(normals, at(50.0, 30.0));
else if (x == 6) gl_FragData[0] = texture2D(specular, at(25.0, 10.0));
else gl_FragData[0] = texture2D(normals, at(33.0, 5.0));
}
"#,
)
.unwrap();
let [x0, y0, w, h] = SPRITE;
let normal: Vec<u8> = (0..w * h).flat_map(|i| texel(i % w, i / w)).collect();
let [normals, specular] = crate::paged_maps::build(
ATLAS,
&[crate::paged_maps::SpriteMaps {
rect: SPRITE,
maps: [Some(normal), None],
}],
16384,
);
assert!(specular.is_none());
let normals = normals.unwrap();
let map = device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Fixture paged normal map"),
size: wgpu::Extent3d {
width: normals.width,
height: normals.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&normals.pixels,
)
.create_view(&Default::default());
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
paged_maps: true,
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
sprite_rects: &[],
atlas: None,
material_maps: None,
lightmap: map.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
&Default::default(),
)
.expect("the pack runs");
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// As the entity passes are (`paged`), with the maps the renderer hands over.
pass.paged = true;
post.set_paged_maps([Some(map), None]);
post.copied |= 1;
post.resize(&device, [8, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [8, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 8,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let local = |x: u32, y: u32| texel(x - x0, y - y0);
let expected = [
local(20, 4),
local(39, 23),
local(31, 15),
local(32, 16),
crate::sprites::FLAT_NORMAL,
crate::sprites::FLAT_NORMAL,
crate::sprites::NO_SPECULAR,
local(33, 5),
];
for (i, e) in expected.iter().enumerate() {
assert_eq!(&data[i * 4..i * 4 + 4], e, "pixel {i}");
}
std::fs::remove_dir_all(root).unwrap();
}
@@ -61,6 +61,9 @@ mod shadow_faces_tests;
#[cfg(test)]
mod r11f_image_tests;
#[cfg(test)]
mod work_group_fold_tests;
#[cfg(test)]
mod forced_flip_tests;
@@ -430,6 +433,9 @@ struct Pass {
/// What `texture` reads instead of the block atlas.
atlas: Option<wgpu::TextureView>,
material_maps: bool,
/// Draws the shared atlas's geometry: `normals` and `specular` are its paged maps
/// (`PackPost::set_paged_maps`), as its programs were translated to read them.
paged: bool,
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
ids: bool,
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
@@ -674,6 +680,8 @@ pub struct PackPost {
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
material_maps: Option<[wgpu::TextureView; 2]>,
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
paged_maps: [Option<wgpu::TextureView>; 2],
lightmap: wgpu::TextureView,
deferred_pre: u16,
composite_pre: u16,
@@ -2179,6 +2187,7 @@ fn build_pass(
draw,
atlas: geometry.and_then(|g| g.atlas.clone()),
material_maps: geometry.is_some_and(|g| g.material_maps),
paged: false,
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2319,6 +2328,7 @@ fn build_compute(
draw: false,
atlas: None,
material_maps: false,
paged: false,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -2357,6 +2367,13 @@ impl Refused {
}
impl PackPost {
/// The shared atlas's normal and specular paged maps (`paged_maps.rs`), which the programs
/// drawing its geometry read as `normals` and `specular`; set before the first frame (a pass's
/// bind groups are kept once made). A missing one reads as the dummy (flat, no specular).
pub fn set_paged_maps(&mut self, maps: [Option<wgpu::TextureView>; 2]) {
self.paged_maps = maps;
}
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
///
@@ -2780,6 +2797,13 @@ impl PackPost {
ids: None,
cull: false,
});
// Programs drawing the shared atlas's geometry read its paged maps (`pack_entities::inputs`).
let paged = |pass: Option<Pass>| {
pass.map(|mut pass| {
pass.paged = true;
pass
})
};
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
@@ -2792,15 +2816,15 @@ impl PackPost {
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
paged(build(p, Some(&decal)))
} else {
build(p, Some(g))
paged(build(p, Some(g)))
}
})
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
.and_then(|(p, g)| paged(build(p, Some(g))))
});
let entities = entity_vertices
.filter(|_| (entity_passes.iter().chain(&shadow_entities)).any(Option::is_some))
@@ -2843,7 +2867,7 @@ impl PackPost {
ids: None,
cull: false,
};
build(p, Some(&g)).map(|pass| (pass, geometry))
paged(build(p, Some(&g))).map(|pass| (pass, geometry))
});
// Translucent held items, drawn from the hand's own buffer.
let hand_water = post.hand_water.and_then(|p| {
@@ -2857,7 +2881,7 @@ impl PackPost {
ids: None,
cull: false,
};
build(p, Some(&g))
paged(build(p, Some(&g)))
});
let weather = post
.weather
@@ -3296,6 +3320,7 @@ impl PackPost {
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
material_maps: world.material_maps,
paged_maps: [None, None],
lightmap: world.lightmap,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
@@ -3656,6 +3681,12 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals if pass.paged => {
self.paged_maps[0].as_ref().unwrap_or(&self.dummy_normal)
}
Source::Specular if pass.paged => {
self.paged_maps[1].as_ref().unwrap_or(&self.dummy_black)
}
Source::Normals => self
.material_maps
.as_ref()
@@ -3756,6 +3787,16 @@ impl PackPost {
[n(0), n(1), 1]
});
let (pipeline, groups) = (pipeline.clone(), groups);
// wgpu panics on a dispatch over its limit. The translator reshapes a large
// `workGroups`; a `workGroupsRender` that large is not run.
let most = device.limits().max_compute_workgroups_per_dimension;
if groups.iter().any(|&n| n > most) {
log::warn!(
"shader pack: {}: {groups:?} work groups is over {most} per dimension; not run",
pass.name
);
return;
}
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
pack_images::snapshot(encoder, &self.images[image]);
}
@@ -6256,6 +6297,34 @@ mod tests {
);
}
#[test]
fn an_image_is_made_in_its_layout_qualifiers_format() {
// `image.x = … RGB10_A2 …` read through `layout(rgb10_a2) image2D x`.
for q in [
"rgba32f",
"rgba16f",
"rg16f",
"r11f_g11f_b10f",
"r32f",
"rgba16",
"rgb10_a2",
"rgba8",
"rg8",
"r16",
"rgba16_snorm",
"rgba8_snorm",
"r8_snorm",
"rgba32i",
"r16i",
"rgb10_a2ui",
"rgba8ui",
"r32ui",
] {
let def = Format::parse(q).unwrap();
assert_eq!(Some(pack_images::format(def, true)), image_format(q), "{q}");
}
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -6301,6 +6370,10 @@ mod destination_tests;
#[path = "pack_material_tests.rs"]
mod material_tests;
#[cfg(test)]
#[path = "pack_paged_maps_tests.rs"]
mod paged_maps_tests;
#[cfg(test)]
mod rgb_alpha_tests;
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { 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,item_id_declared:false,far_depth:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { 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,item_id_declared:false,far_depth:false,paged_maps:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -719,6 +719,7 @@ fn shadow_depth_map_reads(
alpha_test: None,
item_id_declared: false,
far_depth: false,
paged_maps: false,
}),
..Default::default()
};
@@ -46,6 +46,7 @@ fn pack() -> Post {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: 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);\
@@ -0,0 +1,57 @@
//! A `workGroups` over wgpu's 65535 per dimension runs, and its programs read the work group
//! built-ins of the dispatch they declared. The programs are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
// 70000 x 1 x 2 groups; the last 64 of the second layer each paint one pixel, green when
// the built-ins agree with that dispatch.
add(
"composite.csh",
"#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(70000,1,2);\n\
layout(rgba8) writeonly uniform image2D colorimg0;\n\
void main() {\n uint first = 70000u - 64u;\n\
if (gl_WorkGroupID.z != 1u || gl_WorkGroupID.x < first) return;\n\
uint k = gl_WorkGroupID.x - first;\n\
bool ok = gl_WorkGroupID.y == 0u && gl_NumWorkGroups == uvec3(70000u, 1u, 2u)\n\
&& gl_GlobalInvocationID == gl_WorkGroupID;\n\
imageStore(colorimg0, ivec2(k % 8u, k / 8u), ok ? vec4(0, 1, 0, 1) : vec4(1, 0, 0, 1));\n}\n",
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nin vec2 uv;\n\
void main() { gl_FragColor = texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0); }\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-fold", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn a_dispatch_over_the_limit_reads_its_declared_work_groups() {
let Some(gpu) = Fixture::new() else { return };
let post = pack();
assert!(post.notes.is_empty(), "{:?}", post.notes);
let mut post = gpu.build(post);
let pixels = gpu.render(&mut post, [8, 8]);
for (k, got) in pixels.iter().enumerate() {
assert_eq!(*got, [0, 255, 0, 255], "pixel {k}; notes {:?}", post.notes);
}
}
@@ -104,6 +104,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
}
}
@@ -131,6 +132,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
alpha_test: None,
item_id_declared: false,
far_depth: true,
paged_maps: false,
}
}
@@ -46,6 +46,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}),
item_id_declared: false,
far_depth: false,
paged_maps: false,
}
}
@@ -124,6 +124,10 @@ impl<T> Resident<T> {
pub fn get_mut(&mut self, slot: u32) -> &mut T {
&mut self.items[slot as usize].1
}
/// Padded mesh bounds of the section in `slot`, relative to its origin.
pub fn bounds(&self, slot: u32) -> Option<[Vec3; 2]> {
self.bounds[slot as usize]
}
/// Sections from now on count from `min_y` (a level of another column).
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
+17 -2
View File
@@ -46,6 +46,9 @@ pub fn star_triangles() -> Vec<[f32; 3]> {
struct Uniform {
clip_from_sky: [[f32; 4]; 4],
color: [f32; 4],
hdr_settings: [f32; 4],
hdr_shares: [f32; 4],
hdr_light: [f32; 4],
}
pub(super) struct Stars {
@@ -93,7 +96,9 @@ impl Stars {
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Stars shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("stars.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl")).into(),
),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Stars layout"),
@@ -129,7 +134,13 @@ impl Stars {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_stars"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
@@ -165,11 +176,15 @@ impl Stars {
encoder: &mut wgpu::CommandEncoder,
clip_from_sky: Mat4,
brightness: f32,
globals: &super::Globals,
) {
let b = brightness.clamp(0.0, 1.0);
let uniform = Uniform {
clip_from_sky: clip_from_sky.to_cols_array_2d(),
color: [b; 4],
hdr_settings: globals.hdr_settings,
hdr_shares: globals.hdr_shares,
hdr_light: globals.hdr_light,
};
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
}
@@ -4,6 +4,9 @@ struct Stars {
clip_from_sky: mat4x4<f32>,
// starBrightness in every channel (RenderSystem.setShaderColor).
color: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> stars: Stars;
@@ -16,5 +19,8 @@ struct Stars {
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
@fragment fn fs_stars() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
@@ -8,6 +8,10 @@ pub(super) fn pipelines(
) -> [wgpu::RenderPipeline; 3] {
std::array::from_fn(|i| {
let mut constants = sprite_constants.to_vec();
constants.push((
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
));
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
// The ordinary Vertex ABI is unchanged. This material's private payload uses
// normal's four bytes for a float z, and glint UV for float normal x/y.
@@ -65,13 +65,18 @@ impl Visbuffer {
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.chain([
("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(color == super::LINEAR_FRAME)),
),
])
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"))
.into(),
(super::terrain_shader() + include_str!("../visbuffer.wgsl")).into(),
),
});
let fragment = wgpu::ShaderStages::FRAGMENT;
@@ -79,6 +79,7 @@ pub struct Post {
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
}
impl Post {
@@ -258,6 +259,19 @@ impl Post {
taa_frame: 0,
taa_previous: None,
taa_this_frame: bytemuck::Zeroable::zeroed(),
light_key: None,
}
}
// History stores the baked gain as well as the picture. Reject it before jitter records
// the next frame whenever that gain or the frame's representation changes.
pub(super) fn light_representation(
&mut self,
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
) {
if self.light_key != key {
self.taa_previous = None;
self.light_key = key;
}
}
@@ -730,6 +744,42 @@ mod tests {
}
}
#[test]
fn written_light_changes_reject_history_before_the_next_jitter() {
let Some((device, _)) = test_device() else {
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
post.taa = true;
if !post.supports_taa() {
return;
}
let key = (
[5.0, 4.0, 1.0, 0.0],
[0.2, 0.5, 0.6, 1.0],
[[0.1; 4], [0.4; 4]],
);
for key in [Some(key), None, Some(key)] {
post.light_representation(key);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.light_representation(key);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
}
for field in 0..16 {
let mut changed = key;
match field {
0..=3 => changed.0[field] += 0.01,
4..=7 => changed.1[field - 4] += 0.01,
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
}
post.light_representation(Some(changed));
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
}
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -33,7 +33,7 @@ pub(super) fn pipeline(
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_entity"),
entry_point: Some("fs_entity_unmarked"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
@@ -67,7 +67,7 @@ struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
return Out(vec4(pos,1.0),color);
}
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
@fragment fn fs_entity_unmarked(v:Out)->@location(0) vec4<f32> { return v.color; }
"#
.into(),
),
+88 -9
View File
@@ -532,6 +532,54 @@ pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// GPU Culling's setting: off, on where it is offered, or Auto, which has it on only where the
/// CPU limits the frame and it makes the frame faster (`autotune::GpuCullingAuto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GpuCullingMode {
#[default]
Off,
On,
Auto,
}
impl GpuCullingMode {
pub const ALL: [GpuCullingMode; 3] = [
GpuCullingMode::Off,
GpuCullingMode::On,
GpuCullingMode::Auto,
];
/// The value in `horizon-graphics.txt`: `true` and `false` as the switch wrote them.
pub fn key(self) -> &'static str {
match self {
Self::Off => "false",
Self::On => "true",
Self::Auto => "auto",
}
}
/// The word `horizon-ctl gpu-culling` takes.
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::On => "on",
Self::Auto => "auto",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::On => "On",
Self::Auto => "Auto",
}
}
fn parse(s: &str, default: Self) -> Self {
match s {
"true" | "on" => Self::On,
"false" | "off" => Self::Off,
"auto" => Self::Auto,
_ => default,
}
}
}
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
@@ -672,8 +720,9 @@ pub struct Graphics {
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
/// needs a new key, as `batchTranslucent` did.
pub gpu_culling: bool,
/// needs a new key, as `batchTranslucent` did. Auto turns it on only where the CPU limits the
/// frame and it makes the frame faster (`autotune`).
pub gpu_culling: GpuCullingMode,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Minimum entity population for coarse AABB culling; zero disables it.
@@ -684,6 +733,8 @@ pub struct Graphics {
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Draw regular ice opaque in the solid pass, without its see-through texture.
pub opaque_ice: bool,
/// Cache settled shulker-box models.
pub cache_shulker_boxes: bool,
/// Cache sign-text geometry.
@@ -786,11 +837,12 @@ impl Default for Graphics {
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
gpu_culling: false,
gpu_culling: GpuCullingMode::Off,
entity_culling: EntityCullingMode::Off,
entity_culling_precheck: 64,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
opaque_ice: false,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
cache_signs: true,
@@ -885,13 +937,14 @@ impl Graphics {
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
"gpuCulling" => g.gpu_culling = GpuCullingMode::parse(value, g.gpu_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"entityCullingPrecheck" => {
g.entity_culling_precheck = number(g.entity_culling_precheck).min(512)
}
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"opaqueIce" => g.opaque_ice = flag(g.opaque_ice),
"blockEntityCache" => {
let enabled = flag(false);
g.cache_shulker_boxes = enabled;
@@ -986,7 +1039,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\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:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -995,6 +1048,7 @@ impl Graphics {
self.entity_culling_precheck,
self.particle_culling.name(),
self.leaf_culling.name(),
bool_text(self.opaque_ice),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
bool_text(self.cache_decorated_pots),
@@ -1041,7 +1095,7 @@ impl Graphics {
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
bool_text(self.gpu_culling),
self.gpu_culling.key(),
);
text += &self.blue_map.serialize();
text
@@ -1181,11 +1235,12 @@ mod tests {
servers: Vec::new(),
},
cave_culling: false,
gpu_culling: true,
gpu_culling: GpuCullingMode::On,
entity_culling: EntityCullingMode::Synchronous,
entity_culling_precheck: 128,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
opaque_ice: true,
cache_shulker_boxes: true,
cache_signs: true,
cache_decorated_pots: true,
@@ -1271,8 +1326,32 @@ mod tests {
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// GPU Culling is opt-in: only the file or its switch turns it on.
assert!(!Graphics::default().gpu_culling);
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
assert_eq!(Graphics::default().gpu_culling, GpuCullingMode::Off);
assert_eq!(
Graphics::parse("gpuCulling:true\n").gpu_culling,
GpuCullingMode::On
);
// Auto is a third value of the same key. A file from before it reads as before, and an
// older Horizon reading `auto` finds a value it doesn't know and keeps Off.
assert_eq!(
Graphics::parse("gpuCulling:auto\n").gpu_culling,
GpuCullingMode::Auto
);
assert_eq!(
Graphics::parse("gpuCulling:false\n").gpu_culling,
GpuCullingMode::Off
);
assert_eq!(
Graphics::parse("gpuCulling:maybe\n").gpu_culling,
GpuCullingMode::Off
);
for mode in GpuCullingMode::ALL {
let g = Graphics {
gpu_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
}
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
File diff suppressed because it is too large. Load diff
+56 -59
View File
@@ -154,61 +154,8 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(0.0);
}
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
struct Lit {
light: vec3<f32>,
lifted: bool,
};
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && a >= 0.75 && a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
light *= max(gain, 1.0);
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if params.debug > 0.5 && params.world > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
return Lit(min(light, vec3(params.peak)), lifted);
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
}
// Calibration: above its controls the test picture, straight in nits, past
@@ -258,19 +205,69 @@ fn display(light: vec3<f32>) -> vec4<f32> {
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
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).light;
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);
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
// the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
return max(0.6 * headroom, 1.0);
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
// and no channel passes the peak.
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
if top <= tone_knee(headroom) {
return 1.0;
}
let peak = pq(vec3(headroom * paper)).x;
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
let e = pq(vec3(top * paper)).x / source;
let max_lum = peak / source;
let ks = 1.5 * max_lum - 0.5;
if e >= 1.0 {
return headroom / top;
}
if e <= ks {
return 1.0;
}
let t = (e - ks) / (1.0 - ks);
let t2 = t * t;
let t3 = t2 * t;
let p = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + (t3 - 2.0 * t2 + t) * (1.0 - ks) + (3.0 * t2 - 2.0 * t3) * max_lum;
return pq_nits(vec3(p * source)).x / paper / top;
}
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
// knee stays exactly as stored. With no headroom, a clip at white.
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
if params.peak <= 1.0 {
return vec4(min(g, vec3(1.0)), 1.0);
}
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
let s = tone_scale(top, params.peak, params.paper * 80.0);
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
}
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
@@ -0,0 +1,76 @@
override HDR_WRITE_LIGHT: bool = false;
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
// `eased` for the last pass, which eases it into the peak itself; the layered frame leaves that
// to the tone curve (fs_tonemap).
struct Lit {
light: vec3<f32>,
lifted: bool,
};
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && a >= 0.75 && a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (settings.y - 1.0) * (1.0 - a) / 0.24;
let v = top * gain;
let knee = max(0.6 * settings.x, 1.0);
var out = v;
if eased && v > knee && settings.x > knee {
out = knee + (settings.x - knee) * (1.0 - exp(-(v - knee) / (settings.x - knee)));
}
light *= select(gain, out / max(top, 1e-4), eased && v > knee);
}
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
// brighter, and where in the band how much (hdr_mark).
if shows && a < 0.75 {
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
if eased {
gain = min(gain, settings.x / max(top, 1e-4));
}
light *= max(gain, 1.0);
lifted = true;
}
}
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
// colour with a magenta tint, so a lift shows on any colour.
if settings.w > 0.5 && settings.z > 0.5 {
if lifted {
light *= vec3(1.0, 0.6, 1.0);
} else {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
if eased {
light = min(light, vec3(settings.x));
}
return Lit(light, lifted);
}
// Marks are decoded at the precision of the original 8-bit frame, even in a float target.
fn hdr_quantized_mark(mark: f32) -> f32 {
return round(saturate(mark) * 255.0) / 255.0;
}
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
let shows = settings.z > 0.5 && settings.x > 1.0;
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
}
+138 -55
View File
@@ -100,12 +100,18 @@ pub struct HudAtlas {
/// 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>>,
/// 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>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
/// Their emissive overlays (`_e`), packed here too.
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
/// ETF's blinking frames (`_blink`), packed here too.
pub blinks: horizon_optifine::blink::Blinks,
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -888,6 +894,10 @@ impl HudAtlas {
&[],
&crate::gfx::core_lines::GLUE,
);
let sky =
|name| pack_text::load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
atlas.sky_shader = sky("sky");
atlas.stars_shader = sky("stars");
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
@@ -967,6 +977,7 @@ impl HudAtlas {
revision: Self::next_revision(),
pixels: Vec::new(),
packed: None,
material_maps: Default::default(),
galactic_widths: [0; 128],
width: 1,
height: 1,
@@ -981,6 +992,8 @@ impl HudAtlas {
glint_armor: (Vec::new(), 1, 1),
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures: Default::default(),
emissive: Arc::default(),
blinks: Default::default(),
@@ -1029,6 +1042,7 @@ impl HudAtlas {
(glyph_top, height - glyph_top),
white,
),
material_maps: Default::default(),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
@@ -1045,6 +1059,8 @@ impl HudAtlas {
white,
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
@@ -1192,9 +1208,21 @@ impl HudAtlas {
}
}
}
// The title panorama and the New World preset pictures load on their own. The
// Realms world-slot pictures are drawn from this atlas (vanilla draws their
// textures full size): the three faces and `presets/isles.png` stay, costing
// one 1024-tall shelf.
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("presets")
let s = p.to_string_lossy().replace('\\', "/");
let slot_picture = [
"textures/gui/title/background/panorama_0.png",
"textures/gui/title/background/panorama_2.png",
"textures/gui/title/background/panorama_3.png",
"textures/gui/presets/isles.png",
]
.iter()
.any(|kept| s.ends_with(kept));
slot_picture || (!s.contains("title/background") && !s.contains("presets"))
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
@@ -1269,61 +1297,87 @@ impl HudAtlas {
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
// A texture's LabPBR maps, as decoded (width, height, RGBA8), for shader packs.
type Maps = [Option<(u32, u32, Vec<u8>)>; 2];
let read_maps = full && crate::sprites::labpbr_maps();
type Decoded = (
PackedImage,
Option<TextureFlags>,
Option<(u32, u32)>,
bool,
Option<Box<Maps>>,
);
let decode_one =
|path: &PathBuf| -> Option<Decoded> {
let name = if let Some(name) = named.get(path) {
name.clone()
} else {
match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta = horizon_core::prepared_files::read_to_string(
&path.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames =
animation.is_some() || name.starts_with("block/") || name.starts_with("item/");
let (w, h, pixels) = if frames {
first_frame(image, animation.as_ref())
} else {
image
};
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
.as_ref()
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
.map(|(dw, dh)| {
let side = dw.min(dh);
if frames { (side, side) } else { (dw, dh) }
})
.filter(|&size| size != (w, h));
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
let maps = read_maps
.then(|| {
let stem = path.file_stem()?.to_str()?;
let maps: Maps = ["_n", "_s"].map(|suffix| {
decode(&path.with_file_name(format!("{stem}{suffix}.png"))).ok()
});
maps.iter().any(Option::is_some).then(|| Box::new(maps))
})
.flatten();
// The Realms world-slot pictures are 1024-tall GUI textures drawn as
// they are; the cap that keeps other GUI textures small does not
// apply to them.
let tall_picture = matches!(
name.as_str(),
"gui/title/background/panorama_0"
| "gui/title/background/panorama_2"
| "gui/title/background/panorama_3"
| "gui/presets/isles"
);
(gw <= 1024 && (gh <= 512 || tall_picture || name.starts_with("font/")))
.then_some(((name, w, h, pixels), flags, gui_size, animated, maps))
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
let mcmeta =
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
.ok();
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames =
animation.is_some() || name.starts_with("block/") || name.starts_with("item/");
let (w, h, pixels) = if frames {
first_frame(image, animation.as_ref())
} else {
image
};
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
.as_ref()
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
.map(|(dw, dh)| {
let side = dw.min(dh);
if frames { (side, side) } else { (dw, dh) }
})
.filter(|&size| size != (w, h));
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
(name, w, h, pixels),
flags,
gui_size,
animated,
))
};
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
let chunk = files.len().div_ceil(threads).max(1);
let decoded: Vec<_> = std::thread::scope(|scope| {
@@ -1342,7 +1396,11 @@ impl HudAtlas {
let mut gui_sizes = HashMap::new();
let mut animated: Vec<String> = Vec::new();
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
for (image, image_flags, gui_size, is_animated) in decoded {
let mut maps: HashMap<String, Box<Maps>> = HashMap::new();
for (image, image_flags, gui_size, is_animated, image_maps) in decoded {
if let Some(m) = image_maps {
maps.insert(image.0.clone(), m);
}
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
@@ -1517,6 +1575,28 @@ impl HudAtlas {
}
sprite_animations.push((rect, animation));
}
// Each mapped texture's maps at its size in the atlas, paged.
let material_maps = if maps.is_empty() {
Default::default()
} else {
let mapped: Vec<crate::paged_maps::SpriteMaps> = maps
.iter()
.filter_map(|(name, m)| {
let rect = sprites.get(name)?;
let (tw, th) = rect.texels((width, height));
let x = (rect.u0 * width as f32).round() as u32;
let y = (rect.v0 * height as f32).round() as u32;
Some(crate::paged_maps::SpriteMaps {
rect: [x, y, tw, th],
maps: m.each_ref().map(|map| {
let (mw, mh, pixels) = map.as_ref()?;
crate::paged_maps::fit(pixels, (*mw, *mh), (tw, th))
}),
})
})
.collect();
crate::paged_maps::build((width, height), &mapped, MAX_ATLAS_SIZE)
};
let white = sprites["horizon/white"];
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
@@ -1531,6 +1611,7 @@ impl HudAtlas {
);
Ok(Self {
revision: Self::next_revision(),
material_maps: std::sync::Arc::new(material_maps),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
@@ -1546,6 +1627,8 @@ impl HudAtlas {
glint_armor,
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
random_textures,
emissive,
blinks,
+3
View File
@@ -5,6 +5,7 @@ pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod autotune;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
@@ -84,6 +85,7 @@ pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
pub mod pad_glyph;
pub mod paged_maps;
pub mod par;
pub mod particles;
pub mod poi_sections;
@@ -126,6 +128,7 @@ pub mod test_block;
pub mod test_instance_block;
pub mod testgen;
pub mod title_edition;
pub mod touch;
pub mod trims;
pub mod ui;
pub mod vault;
+77 -1
View File
@@ -9,6 +9,7 @@ use crate::{
};
pub mod cache;
mod mesh;
pub mod packed;
use horizon_world::registry::BlockStateId;
use std::{
collections::VecDeque,
@@ -559,7 +560,8 @@ pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
/// holes in mountains (and paths and fields dips). Slabs use the same
/// full-cell approximation as stairs, so distant slab builds remain visible.
fn nearly_full(name: &str) -> bool {
matches!(
name,
@@ -568,6 +570,7 @@ fn nearly_full(name: &str) -> bool {
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
|| name.ends_with("_slab")
|| name.contains("fence")
|| name.contains("_wall")
|| name.contains("bars")
@@ -1414,7 +1417,13 @@ impl Lod {
&edges,
&input.finer,
);
// Sorts the quads by facing side, and clears
// the compact layout: a far tile has no tints
// for `mesher::packed`, so its own records
// (`lod::packed`) are laid out here, from the
// quads in the order the sort left them.
mesh.sort_solid();
crate::lod::packed::lay_out(&mut mesh);
let [ox, _, oz] = job.key.origin();
let size = job.key.size();
mesh.keep = keep_finer
@@ -3977,6 +3986,73 @@ mod tests {
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn slab_materials_survive_chunk_summaries_and_draw_far_faces() -> crate::Result<()> {
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables: Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?),
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let kinds = state_kinds(&facts);
let mut chunk = Chunk::empty(0, 0);
let mut mesher = mesh::Mesher::default();
let mut materials = FxHashSet::default();
// Real top, bottom, double and waterlogged states: a waterlogged slab
// must keep its material rather than become a column of plain water.
for (i, f) in facts.states.iter().enumerate() {
if !f.name.ends_with("_slab") {
continue;
}
materials.insert(f.name.as_ref());
let state = BlockStateId(i as u32);
chunk.set(3, 90, 3, state.0);
let columns = summarize(&chunk, &kinds);
let column = &columns[3 * 16 + 3];
assert_eq!(column.spans.len(), 1, "{} state {i}", f.name);
assert_eq!(column.spans[0].state, state, "{} state {i}", f.name);
assert_eq!((column.spans[0].min, column.spans[0].max), (90, 91));
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, column.spans);
let mesh = mesher.mesh(
TileKey {
x: 0,
z: 0,
level: 0,
},
&columns.iter().collect::<Vec<_>>(),
&FxHashSet::default(),
None,
&context,
&std::array::from_fn(|_| Vec::new()),
&[],
);
// Like stairs, far slabs use the existing full-cell approximation.
assert_eq!(mesh.solid.len(), 6 * 4, "{} state {i}", f.name);
assert!(mesh.translucent.is_empty(), "{} state {i}", f.name);
}
for name in [
"minecraft:smooth_stone_slab",
"minecraft:oak_slab",
"minecraft:cut_copper_slab",
] {
assert!(materials.contains(name), "missing fixture {name}");
}
Ok(())
}
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables,
@@ -1386,6 +1386,52 @@ mod tests {
let p = |i: usize| glam::Vec3::from(q[i].pos);
(p(1) - p(0)).cross(p(2) - p(0))
}
/// Every solid quad the mesher emits packs into a far record, and the
/// record gives the quad back bit for bit (`lod::packed`).
///
/// `lod::packed`'s own tests check its records against its own copy of what
/// `emit_quad` does, which would pass just as well if the two had drifted
/// apart. This checks them against the real mesher, over terrain that makes
/// faces of every axis and winding: a hill of varied heights, a material
/// boundary, a light boundary and water.
#[test]
fn every_quad_the_mesher_emits_packs_and_comes_back() {
let mut columns = columns();
for (i, column) in columns.iter_mut().enumerate() {
let (x, z) = (i % CELLS, i / CELLS);
let top = 4 + (x as i16 % 5) * 2 + (z as i16 % 3);
let state = if (x + z) % 7 == 0 { 3 } else { 1 };
let light = if x % 4 == 0 { 16 } else { 240 };
column.spans = vec![
Span {
light,
..span(0, top, state)
},
// Water over the lower ground, for the fluid faces too.
span(top, 12.max(top + 1), 2),
]
.into();
}
let mesh = mesh(&columns);
let quads = mesh.solid.as_chunks::<4>().0;
assert!(
quads.len() > 50,
"{} quads is too few to say much",
quads.len()
);
for (i, quad) in quads.iter().enumerate() {
let words = crate::lod::packed::pack(quad)
.unwrap_or_else(|| panic!("quad {i} of {} did not pack", quads.len()));
let back = crate::lod::packed::corners(words)
.unwrap_or_else(|| panic!("quad {i}'s record names no face"));
assert_eq!(
bytemuck::cast::<_, [[u32; 6]; 4]>(back),
bytemuck::cast::<_, [[u32; 6]; 4]>(*quad),
"quad {i} came back changed"
);
}
}
#[test]
fn flat_solid_cube_merges_to_six_faces_with_block_scaled_uvs() {
let mut columns = columns();
@@ -0,0 +1,440 @@
//! The compact layout of a far tile's solid quads in the arena: one 24-byte
//! record each instead of four 24-byte vertices.
//!
//! A far tile's quads are not blocks' faces -- they are merged rectangles of a
//! voxel column's exposed side, hundreds of blocks wide -- so none of them fits
//! `mesher::packed`'s record, whose coordinates are a block grid of 16. They
//! are far more regular than a chunk's quads instead: a rectangle in an
//! axis-aligned plane, with one colour, one light and one sprite layer across
//! all four corners, and UVs that `lod::mesh::emit_quad` derives from the
//! positions. So a record holds what that function was given, and
//! terrain.wgsl's `far_corner` does what it did:
//!
//! - word 0: the colour (RGBA8), the same for all four corners;
//! - word 1: the sprite layer, and the light in the high half;
//! - word 2: the plane, the coordinate all four corners share, as an `i32`;
//! - word 3: the rectangle's low edge along the face's first axis, as an `i32`;
//! - word 4: its low edge along the second, as an `i32`;
//! - word 5: the plane's axis, whether the corners are reversed, the
//! rectangle's width and height, and `FULL` clear.
//!
//! Nothing here reasons about which record is right for a quad: `pack` builds
//! one, rebuilds the four vertices from it with the same code the shader
//! mirrors, and keeps it only if they come back bit for bit. A quad no record
//! reproduces -- a fractional coordinate, a rectangle too wide, a face whose
//! UVs were scaled down to fit (`lod::mesh::MAX_PIECES`) -- is kept as its four
//! vertices, exactly as `mesher::packed` keeps one.
use crate::mesher::{TerrainVertex, packed};
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from
/// the record to its first vertex. The same bit `mesher::packed` uses, so the
/// two layouts place their records and spilled vertices alike.
pub use crate::mesher::packed::FULL;
/// The draws' base vertex of a compact far mesh, above `packed::PACKED_BASE`:
/// `FAR_BASE + 4 * slot + corner` is a corner of the quad whose record is at
/// `slot`. terrain.wgsl's `section_corner` reads the layout off the index, so
/// a far tile and a chunk section can share one arena and one pipeline.
pub const FAR_BASE: u32 = 1 << 29;
/// Whether far tiles meshed from now on get this layout: `HORIZON_FAR_PACK=0`
/// stops them and leaves chunk sections' own layout alone, so the two can be
/// told apart in one build. `HORIZON_PACKED_QUADS=0` stops both.
pub fn far_packing() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("HORIZON_FAR_PACK").as_deref() != Ok("0"))
}
/// The widest rectangle a record's 11-bit width and height hold. `lod::mesh`
/// splits a face at `MAX_FACE` = 1024 blocks unless it scales its UVs instead,
/// and a scaled face does not pack, so this is never the binding limit.
const MAX_EXTENT: i64 = (1 << 11) - 1;
/// Which axis a face's plane lies on, and what `lod::mesh::emit_quad` makes of
/// the other two. The names are that function's: `u` is the rectangle's first
/// axis and `v` its second, and the UV of a corner is `[u, -v]` of it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Axis {
/// Up and Down: the plane is y, u is x and v is z.
Y,
/// North and South: the plane is z, u is x and v is y.
Z,
/// West and East: the plane is x, u is z and v is y.
X,
}
impl Axis {
const ALL: [Self; 3] = [Self::Y, Self::Z, Self::X];
fn bits(self) -> u32 {
match self {
Self::Y => 0,
Self::Z => 1,
Self::X => 2,
}
}
#[cfg(test)]
fn from_bits(bits: u32) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.bits() == bits)
}
}
/// A face as `lod::mesh::emit_quad` was given it, which is all a record holds.
#[derive(Clone, Copy, Debug)]
struct Face {
axis: Axis,
/// Whether the corners go round the other way, as they do for Down, South
/// and East (that function's `points.reverse()`).
reversed: bool,
plane: i32,
u0: i32,
v0: i32,
du: u32,
dv: u32,
layer: u16,
light: u16,
color: u32,
}
impl Face {
/// The four vertices of it, as `lod::mesh::emit_quad` writes them. Kept
/// here and not shared with that function because it is what the shader
/// mirrors: the two have to agree, and `a_record_rebuilds_what_the_mesher_
/// emitted` is what holds them to it.
fn vertices(self) -> [TerrainVertex; 4] {
let (p, u0, v0) = (self.plane as f32, self.u0 as f32, self.v0 as f32);
let u1 = u0 + self.du as f32;
let v1 = v0 + self.dv as f32;
let mut points = match self.axis {
Axis::Y => [[u0, p, v1], [u1, p, v1], [u1, p, v0], [u0, p, v0]],
Axis::Z => [[u0, v1, p], [u1, v1, p], [u1, v0, p], [u0, v0, p]],
Axis::X => [[p, v1, u1], [p, v1, u0], [p, v0, u0], [p, v0, u1]],
};
if self.reversed {
points.reverse();
}
let uvs = points.map(|pos| match self.axis {
Axis::Y => [pos[0], -pos[2]],
Axis::Z => [pos[0], -pos[1]],
Axis::X => [pos[2], -pos[1]],
});
let corner = [0, 1].map(|i| {
uvs.iter()
.map(|uv| uv[i])
.fold(f32::INFINITY, f32::min)
.floor()
});
let mut out = [TerrainVertex::tiled([0.0; 3], [0.0; 2], 0, 0, 0); 4];
for ((to, pos), uv) in out.iter_mut().zip(points).zip(uvs) {
*to = TerrainVertex::tiled(
pos,
[uv[0] - corner[0], uv[1] - corner[1]],
self.layer,
self.color,
self.light,
);
}
out
}
fn words(self) -> [u32; 6] {
[
self.color,
u32::from(self.layer) | u32::from(self.light) << 16,
self.plane as u32,
self.u0 as u32,
self.v0 as u32,
self.axis.bits() | u32::from(self.reversed) << 2 | self.du << 3 | self.dv << 14,
]
}
/// The face a record names, or `None` for a record that spills its quad's
/// vertices (`FULL`) or that does not name an axis.
#[cfg(test)]
fn from_words(w: [u32; 6]) -> Option<Self> {
if w[5] & FULL != 0 {
return None;
}
Some(Self {
axis: Axis::from_bits(w[5] & 3)?,
reversed: (w[5] >> 2) & 1 != 0,
plane: w[2] as i32,
u0: w[3] as i32,
v0: w[4] as i32,
du: (w[5] >> 3) & 0x7ff,
dv: (w[5] >> 14) & 0x7ff,
layer: w[1] as u16,
light: (w[1] >> 16) as u16,
color: w[0],
})
}
}
/// A whole number in `i32`, or `None`.
fn whole(v: f32) -> Option<i32> {
(v.fract() == 0.0 && (f32::from(i16::MIN) * 16.0..=f32::from(i16::MAX) * 16.0).contains(&v))
.then_some(v as i32)
}
/// `quad`'s record, if one gives back its four vertices exactly.
pub fn pack(quad: &[TerrainVertex; 4]) -> Option<[u32; 6]> {
let first = quad[0];
// One colour, one light and one layer across the four corners, which is
// what lets a record hold them once.
if quad
.iter()
.any(|v| (v.color, v.light, v.layer) != (first.color, first.light, first.layer))
{
return None;
}
let lo = [0, 1, 2].map(|k| quad.iter().map(|v| v.pos[k]).fold(f32::INFINITY, f32::min));
let hi = [0, 1, 2].map(|k| {
quad.iter()
.map(|v| v.pos[k])
.fold(f32::NEG_INFINITY, f32::max)
});
// Every axis and winding the mesher can have emitted; the round trip below
// says which one it was, so nothing here has to work it out.
for axis in Axis::ALL {
let (plane, u, v) = match axis {
Axis::Y => (1, 0, 2),
Axis::Z => (2, 0, 1),
Axis::X => (0, 2, 1),
};
if lo[plane] != hi[plane] {
continue;
}
let (Some(plane), Some(u0), Some(v0)) = (whole(lo[plane]), whole(lo[u]), whole(lo[v]))
else {
continue;
};
let (Some(u1), Some(v1)) = (whole(hi[u]), whole(hi[v])) else {
continue;
};
let (du, dv) = (i64::from(u1 - u0), i64::from(v1 - v0));
if du > MAX_EXTENT || dv > MAX_EXTENT {
continue;
}
for reversed in [false, true] {
let face = Face {
axis,
reversed,
plane,
u0,
v0,
du: du as u32,
dv: dv as u32,
layer: first.layer,
light: first.light,
color: first.color,
};
let words = face.words();
// The record is kept only where it gives the quad back exactly, so
// a quad this layout cannot hold is spilled rather than altered.
if bytemuck::cast::<_, [[u32; 6]; 4]>(face.vertices())
== bytemuck::cast::<_, [[u32; 6]; 4]>(*quad)
{
return Some(words);
}
}
}
None
}
/// The four corners a record names, as the shader rebuilds them; `None` for a
/// record that spills its quad's vertices. For the tests that hold this layout
/// and `lod::mesh` to each other.
#[cfg(test)]
pub fn corners(words: [u32; 6]) -> Option<[TerrainVertex; 4]> {
Face::from_words(words).map(Face::vertices)
}
/// Lays a far tile's solid quads out compactly in `mesh.packed`, where the
/// renderer reads that layout. Call it after `SectionMesh::sort_solid`, which
/// reorders the quads and clears whatever layout was there.
pub fn lay_out(mesh: &mut crate::mesher::SectionMesh) {
if !packed::packing() || !far_packing() {
return;
}
mesh.packed = layout(&mesh.solid);
mesh.packed_far = !mesh.packed.is_empty();
}
/// A far tile's solid quads laid out compactly: a record each in mesh order,
/// then the vertices of the quads no record fits. Empty where that would not
/// be smaller than the vertices.
pub fn layout(solid: &[TerrainVertex]) -> Vec<[u32; 6]> {
let quads = solid.as_chunks::<4>().0;
let records: Vec<Option<[u32; 6]>> = quads.iter().map(pack).collect();
packed::place(&records, quads)
}
#[cfg(test)]
mod tests {
use super::*;
/// The four corners as bits: `TerrainVertex` has no `PartialEq`, and bit
/// identity is the guarantee the layout makes in any case.
fn words_of(quad: [TerrainVertex; 4]) -> [[u32; 6]; 4] {
bytemuck::cast(quad)
}
fn face() -> Face {
Face {
axis: Axis::Y,
reversed: false,
plane: 71,
u0: -48,
v0: 112,
du: 16,
dv: 7,
layer: 3,
light: 0xF0F0,
color: 0xFF_80_40_20,
}
}
/// Every axis and winding survives the words, so `from_words` and the
/// shader's `far_corner` read what `words` wrote.
#[test]
fn a_face_survives_its_record() {
for axis in Axis::ALL {
for reversed in [false, true] {
let want = Face {
axis,
reversed,
..face()
};
let got = Face::from_words(want.words()).expect("a record names a face");
assert_eq!(
words_of(got.vertices()),
words_of(want.vertices()),
"{axis:?} {reversed}"
);
assert_eq!((got.axis, got.reversed), (axis, reversed));
assert_eq!((got.plane, got.u0, got.v0), (want.plane, want.u0, want.v0));
assert_eq!((got.du, got.dv), (want.du, want.dv));
assert_eq!(
(got.layer, got.light, got.color),
(want.layer, want.light, want.color)
);
}
}
}
/// The widest and the most distant rectangle a record claims to hold comes
/// back exactly, so the bit fields are wide enough for what they say.
#[test]
fn the_extremes_a_record_claims_come_back() {
let want = Face {
du: MAX_EXTENT as u32,
dv: MAX_EXTENT as u32,
plane: i32::from(i16::MAX),
u0: -8_000_000,
v0: 8_000_000,
layer: u16::MAX,
light: u16::MAX,
color: u32::MAX,
..face()
};
let got = Face::from_words(want.words()).expect("a record names a face");
assert_eq!((got.du, got.dv), (want.du, want.dv));
assert_eq!((got.plane, got.u0, got.v0), (want.plane, want.u0, want.v0));
assert_eq!(
(got.layer, got.light, got.color),
(want.layer, want.light, want.color)
);
}
/// Any quad the mesher could have emitted packs, and unpacks to the very
/// vertices it emitted: this is the whole guarantee the layout makes.
#[test]
fn a_record_rebuilds_what_the_mesher_emitted() {
let mut packed = 0;
for axis in Axis::ALL {
for reversed in [false, true] {
for (du, dv) in [(1, 1), (16, 1), (1, 16), (37, 91), (1024, 1024)] {
let want = Face {
axis,
reversed,
du,
dv,
..face()
};
let quad = want.vertices();
let words = pack(&quad).expect("the mesher's own quad packs");
let back = Face::from_words(words).expect("a record names a face");
assert_eq!(
words_of(back.vertices()),
words_of(quad),
"{axis:?} {reversed} {du}x{dv}"
);
packed += 1;
}
}
}
assert_eq!(packed, 30, "every case packed");
}
/// A quad this layout cannot hold is refused rather than altered: the
/// corners disagreeing on a colour, a fractional coordinate, and a
/// rectangle wider than the width field.
#[test]
fn a_quad_no_record_fits_is_refused() {
let mut uneven = face().vertices();
uneven[2].color ^= 1;
assert!(pack(&uneven).is_none(), "corners differ in colour");
let mut fraction = face().vertices();
fraction[0].pos[0] += 0.5;
assert!(pack(&fraction).is_none(), "a fractional coordinate");
let wide = Face {
du: MAX_EXTENT as u32 + 1,
..face()
};
assert!(pack(&wide.vertices()).is_none(), "wider than the field");
}
/// A tile of quads that all pack takes a quarter of the slots its vertices
/// would, which is the point of the layout.
#[test]
fn a_tile_that_packs_takes_a_quarter_of_the_slots() {
let mut solid = Vec::new();
for n in 0..64 {
solid.extend(
Face {
u0: n * 4,
..face()
}
.vertices(),
);
}
let out = layout(&solid);
assert_eq!(out.len(), solid.len() / 4, "a slot a quad, none spilled");
for (quad, record) in solid.as_chunks::<4>().0.iter().zip(&out) {
let face = Face::from_words(*record).expect("a record names a face");
assert_eq!(words_of(face.vertices()), words_of(*quad),);
}
}
/// A tile whose quads mostly spill keeps the vertices and gives up on the
/// layout rather than growing: `place` is what decides, and this holds it
/// to the same rule `mesher::packed` follows.
#[test]
fn a_tile_that_mostly_spills_keeps_its_vertices() {
let mut solid = Vec::new();
for n in 0..64 {
let mut quad = Face {
u0: n * 4,
..face()
}
.vertices();
quad[1].color ^= 1;
solid.extend(quad);
}
assert!(layout(&solid).is_empty(), "no layout smaller than vertices");
}
}
+94 -5
View File
@@ -6,8 +6,8 @@
//! keeps 7 archives a day, the newest with the highest index.
//!
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
//! as they did before the log existed; other crates' errors only reach the file (their
//! warnings are mostly the Vulkan loader's startup chatter).
//! as they did before the log existed. Other crates, including native mods and their helper
//! crates, reach the file through INFO too, with their target prefixed to identify the source.
use std::collections::{HashMap, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
@@ -424,14 +424,18 @@ fn ours(target: &str) -> bool {
impl log::Log for Logger {
fn enabled(&self, meta: &log::Metadata) -> bool {
meta.level() <= log::Level::Info
&& (ours(meta.target()) || meta.level() <= log::Level::Error)
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let message = record.args().to_string();
let horizon = ours(record.target());
let message = if horizon {
record.args().to_string()
} else {
format!("[{}] {}", record.target(), record.args())
};
let current = std::thread::current();
let thread = match current.name() {
Some("main") => self.main_thread,
@@ -460,7 +464,7 @@ impl log::Log for Logger {
),
_ => {}
}
let stderr = ours(record.target()).then(|| message.clone());
let stderr = horizon.then(|| message.clone());
self.send(thread, level, &message, stderr);
if gate == Gate::Last {
self.write(
@@ -652,6 +656,91 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn native_mod_records_reach_the_file_with_their_targets() {
use log::Log;
let dir =
std::env::temp_dir().join(format!("horizon-native-log-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let (out, receive) = mpsc::channel();
let writer = Writer {
file: Some(open_latest(&dir).unwrap().unwrap()),
dir: dir.clone(),
date: Local::now().date_naive(),
};
let handle = std::thread::spawn(move || writer.run(receive));
let logger = Logger {
out,
recent: Mutex::new(VecDeque::new()),
main_thread: "Render thread",
floods: Mutex::new(Floods::default()),
};
for (target, level, message) in [
("meridian_horizon::save", log::Level::Warn, "save failed"),
("meridian_core::map", log::Level::Info, "map opened"),
("wgpu_core::device", log::Level::Error, "device lost"),
("meridian_core::map", log::Level::Debug, "debug detail"),
("meridian_core::map", log::Level::Trace, "trace detail"),
] {
logger.log(
&log::Record::builder()
.args(format_args!("{message}"))
.level(level)
.target(target)
.build(),
);
}
logger.flush();
drop(logger);
handle.join().unwrap();
let text = fs::read_to_string(dir.join("latest.log")).unwrap();
fs::remove_dir_all(&dir).unwrap();
assert!(
text.contains("[meridian_horizon::save] save failed"),
"{text}"
);
assert!(text.contains("[meridian_core::map] map opened"), "{text}");
assert!(text.contains("[wgpu_core::device] device lost"), "{text}");
assert!(!text.contains("debug detail"));
assert!(!text.contains("trace detail"));
}
#[test]
fn horizon_stderr_and_external_file_records_stay_separate() {
use log::Log;
let (out, receive) = mpsc::channel();
let logger = Logger {
out,
recent: Mutex::new(VecDeque::new()),
main_thread: "Render thread",
floods: Mutex::new(Floods::default()),
};
for target in ["horizon_client::game", "meridian_core", "wgpu_hal::vulkan"] {
logger.log(
&log::Record::builder()
.args(format_args!("diagnostic"))
.level(log::Level::Info)
.target(target)
.build(),
);
let Out::Write {
stderr,
record: (_, _, message),
..
} = receive.try_recv().unwrap()
else {
panic!("a diagnostic must write a record")
};
if ours(target) {
assert_eq!(stderr.as_deref(), Some("diagnostic"));
assert_eq!(message, "diagnostic");
} else {
assert!(stderr.is_none());
assert_eq!(message, format!("[{target}] diagnostic"));
}
}
}
#[test]
fn a_player_dir_keeps_the_shared_files_in_the_game_folder() {
let (game, player) = (Path::new("/games/shared"), Path::new("/games/players/alex"));
+36 -12
View File
@@ -347,6 +347,10 @@ pub struct SectionMesh {
/// model order before.
pub sides: Sides,
pub translucent: Vec<TerrainVertex>,
/// Whether `packed` holds far tile records (`lod::packed`) rather than
/// section records (`mesher::packed`): the two are read apart, by the base
/// vertex their draws use.
pub packed_far: bool,
/// `solid` in the compact layout (`packed`), when the renderer reads it and it is
/// smaller; empty otherwise. Made by `sort_solid`.
pub packed: Vec<[u32; 6]>,
@@ -379,6 +383,7 @@ impl Default for SectionMesh {
sides: Sides::default(),
translucent: Vec::new(),
packed: Vec::new(),
packed_far: false,
solid_tints: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
@@ -413,6 +418,7 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.packed.clear();
self.packed_far = false;
self.solid_tints.clear();
self.block_entities.clear();
self.lights.clear();
@@ -442,6 +448,7 @@ impl SectionMesh {
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
self.bounds = self.compute_bounds();
self.packed.clear();
self.packed_far = false;
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
self.packed = packed::layout(&self.solid, &self.solid_tints);
}
@@ -652,7 +659,11 @@ struct Info {
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
/// `instanceof IceBlock` (frosted ice too): fluids read it.
ice: bool,
/// Regular ice, the block the Opaque Ice option draws opaque; frosted ice
/// keeps its see-through, cracking look.
opaque_ice: bool,
sturdy: u8,
/// Occlusion face per direction: 0 empty, 1 the full-block singleton, 2 partial.
face: [u8; 6],
@@ -844,6 +855,7 @@ impl MeshTables {
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
opaque_ice: s.has_class("IceBlock") && !s.has_class("FrostedIceBlock"),
sturdy: s.sturdy_full,
face,
cover,
@@ -1251,6 +1263,8 @@ fn row_masks_match_cell_definition() {
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod opaque_ice_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
@@ -1580,6 +1594,8 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// The `opaqueIce` option: regular ice draws opaque in the solid pass.
pub opaque_ice: bool,
/// Sprite layers a shader pack moves into the translucent pass, by layer.
pub translucent_layers: &'a [bool],
/// What the worker does with leaf faces inside a canopy.
@@ -1705,6 +1721,9 @@ impl Mesher<'_> {
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
// Opaque Ice: faces between two ice blocks are hidden, as they are
// between two opaque blocks.
let opaque_ice = self.opaque_ice && state.opaque_ice && other.opaque_ice;
let culled_leaf = match self.leaf_culling {
LeafCullingMode::Hollow | LeafCullingMode::Hidden => {
state.leaves
@@ -1722,6 +1741,7 @@ impl Mesher<'_> {
_ => false,
};
visible[d as usize] = !opaque_leaves
&& !opaque_ice
&& !culled_leaf
&& self
.visibility
@@ -1950,9 +1970,11 @@ impl Mesher<'_> {
// The face's own UVs, already packed: no per-vertex rounding.
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
};
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
// Opaque leaves (forceOpaque), leaves a leaf-culling mode proved internal and Opaque
// Ice draw in the solid layer whatever their sprite.
let force_solid = !self.cutout_leaves && state.leaves
|| interior_leaf
|| self.opaque_ice && state.opaque_ice;
let own = TileLayer::Layer {
layer: face.layer as u16,
translucent: face.translucent,
@@ -1972,8 +1994,8 @@ impl Mesher<'_> {
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0), tint),
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -1982,7 +2004,7 @@ impl Mesher<'_> {
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
let piece = piece.map(|c| c.0);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece, tint);
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
}
}
}
@@ -2031,17 +2053,17 @@ impl Mesher<'_> {
}
}
}
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`force_solid`: no
/// alpha cutout whatever the sprite).
fn quad(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
force_solid: bool,
tile: TileLayer,
v: [TerrainVertex; 4],
tint: Option<u32>,
) {
self.quad_as(out, solid_leaves, tile, v, true, tint);
self.quad_as(out, force_solid, tile, v, true, tint);
}
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
fn translucent(&self, tile: TileLayer) -> bool {
@@ -2065,7 +2087,7 @@ impl Mesher<'_> {
fn quad_as(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
force_solid: bool,
tile: TileLayer,
mut v: [TerrainVertex; 4],
by_sprite: bool,
@@ -2082,14 +2104,16 @@ impl Mesher<'_> {
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
let translucent = if by_sprite {
let translucent = if force_solid {
false
} else if by_sprite {
self.translucent(tile)
} else {
translucent
};
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
// skips its clear top row). All four vertices share the layer.
let layer = if opaque && !translucent || solid_leaves {
let layer = if opaque && !translucent || force_solid {
layer | OPAQUE_LAYER
} else {
layer
@@ -343,6 +343,7 @@ mod tests {
opaque_cube: opaque,
blocks_flow: false,
ice: false,
opaque_ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
@@ -83,6 +83,7 @@ fn mesh(
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: mode,
};
@@ -0,0 +1,140 @@
//! Opaque Ice through the real mesher: regular ice moves from the translucent
//! pass to the solid pass without its texture alpha, keeps its faces toward
//! air, loses the faces between two ice blocks, and leaves other translucent
//! blocks alone. Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::Chunk,
models,
};
struct Fixture {
assets: Assets,
biomes: Biomes,
render: RenderAssets,
visibility: horizon_world::block_render::FaceVisibility,
tables: MeshTables,
}
fn fixture() -> Fixture {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
Fixture {
assets,
biomes,
render,
visibility,
tables,
}
}
fn state(f: &Fixture, name: &str) -> u32 {
f.assets.registry.default_state(name).unwrap().0
}
fn mesh(f: &Fixture, chunk: &Chunk, opaque_ice: bool) -> SectionMesh {
let region = [[Some(chunk); 3]; 3];
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
shader_pack: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
biomes: &f.biomes,
visibility: f.visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
opaque_ice,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
mesher.mesh(&region, 0)
}
const BASE: i32 = MIN_Y + 4;
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn off_keeps_regular_ice_in_the_translucent_pass() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
chunk.set(8, BASE, 8, state(&f, "minecraft:ice"));
let mesh = mesh(&f, &chunk, false);
assert!(mesh.solid.is_empty());
assert_eq!(mesh.translucent.len(), 24);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn on_draws_regular_ice_opaque_in_the_solid_pass() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
chunk.set(8, BASE, 8, state(&f, "minecraft:ice"));
let mesh = mesh(&f, &chunk, true);
assert!(mesh.translucent.is_empty());
let quads = mesh.solid.as_chunks::<4>().0;
assert_eq!(quads.len(), 6);
for quad in quads {
assert!(
quad.iter().all(|v| v.layer & OPAQUE_LAYER != 0),
"an opaque ice quad kept its texture alpha"
);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn on_culls_the_face_between_two_ice_blocks() {
let f = fixture();
let ice = state(&f, "minecraft:ice");
let mut chunk = Chunk::empty(0, 0);
chunk.set(7, BASE, 8, ice);
chunk.set(8, BASE, 8, ice);
let mesh = mesh(&f, &chunk, true);
assert!(mesh.translucent.is_empty());
// Two cubes, six faces each, minus the face they share.
assert_eq!(mesh.solid.len(), 10 * 4);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn on_leaves_other_translucent_blocks_alone() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
chunk.set(4, BASE, 8, state(&f, "minecraft:ice"));
chunk.set(12, BASE, 8, state(&f, "minecraft:slime_block"));
let mesh = mesh(&f, &chunk, true);
assert_eq!(mesh.solid.len(), 24, "only the ice cube is solid");
assert!(
mesh.translucent.len() >= 24,
"the slime cube is still translucent"
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn on_leaves_frosted_ice_translucent() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
chunk.set(8, BASE, 8, state(&f, "minecraft:frosted_ice"));
let mesh = mesh(&f, &chunk, true);
assert!(mesh.solid.is_empty());
assert_eq!(mesh.translucent.len(), 24);
}
@@ -208,6 +208,18 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
.zip(tints)
.map(|(q, &t)| pack_quad(q, t))
.collect();
place(&records, quads)
}
/// A mesh's solid range from a record per quad, `None` where no record fits:
/// the records in mesh order, padded to a multiple of 4 slots, then the four
/// vertices of each quad without one, whose record holds the distance to them
/// (`FULL`). Empty where that would not be smaller than the vertices, which is
/// what makes the layout never a loss.
///
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
/// spill and pad the same way.
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
@@ -229,7 +241,7 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
}));
}
out.resize(n + pad, [0; 6]);
for (q, record) in quads.iter().zip(&records) {
for (q, record) in quads.iter().zip(records) {
if record.is_none() {
out.extend(
q.iter()
@@ -181,6 +181,7 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
@@ -237,6 +238,7 @@ fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
@@ -289,6 +291,7 @@ fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
@@ -364,6 +367,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
@@ -513,6 +517,7 @@ fn a_shader_pack_s_still_water_top_starts_at_its_south_west_corner() {
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
@@ -92,6 +92,7 @@ fn info(solid: bool) -> Info {
opaque_cube: solid,
blocks_flow: false,
ice: false,
opaque_ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
@@ -21,7 +21,11 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let color = oit_cloud_color(in, front, true);
var color = oit_cloud_color(in, front, true);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
globals.hdr_settings, globals.hdr_shares), color.a);
}
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
return vec4(color.rgb * color.a, color.a) * seen;
@@ -12,6 +12,10 @@
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
}
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_color(in, front), in.clip);
}
// `OIT_ENTITY_SHADOW`.
@@ -56,6 +60,9 @@
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
var color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -9,19 +9,25 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
}
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
// depth), combined by max blending.
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_oit_bounds(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let color = oit_terrain_color(in);
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
// Stage 2.
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
@fragment fn fs_oit_transmittance(wire: TerrainWire) -> OitCoefficients {
let in = from_wire(wire);
let color = oit_terrain_color(in);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
// Stage 3: premultiplied colour, weighted by the transmittance in front.
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_oit_accumulate(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let color = oit_terrain_color(in);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
@@ -30,6 +36,13 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
// The fog colour is premultiplied by the same weight.
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, f * globals.fog_color.a));
// Convert the surface before weighting; its mark never belongs to the light behind it.
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
}
return vec4(rgb, weighted.a);
}
+164 -128
View File
@@ -312,6 +312,7 @@ impl Opt {
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
Opt::PadInvertX => "horizon.options.invert_x",
Opt::PadInvertY => "horizon.options.invert_y",
Opt::PadCraftOnServers => "horizon.options.craft_on_servers",
Opt::PadToggleSneak => "horizon.options.sneak_button",
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
@@ -849,6 +850,7 @@ impl Opt {
}),
Opt::PadInvertX => on_off(s.controller.invert_x),
Opt::PadInvertY => on_off(s.controller.invert_y),
Opt::PadCraftOnServers => on_off(s.controller.craft_on_servers),
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
"options.key.toggle"
} else {
@@ -1156,6 +1158,9 @@ impl Opt {
}
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
Opt::PadCraftOnServers => {
s.controller.craft_on_servers = !s.controller.craft_on_servers
}
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
Opt::PadNintendoButtons => {
s.controller.nintendo_letters = !s.controller.nintendo_letters
@@ -1654,7 +1659,7 @@ pub struct OptionsUi {
pub minecart_improvements: bool,
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
pub controller: crate::controller_ui::ControllerUi,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
@@ -2095,9 +2100,6 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
self.graphics.typing()
}
Page::Controller | Page::PadBinds => self.controller.typing(),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => match self.shell() {
@@ -2210,9 +2212,6 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if let Some(at) = Self::graphics_page(page) {
self.graphics.open(at);
}
if let Some(at) = Self::controller_page(page) {
self.controller.open(at);
}
@@ -2222,6 +2221,9 @@ impl OptionsUi {
if let Some(shell) = self.shell_mut() {
shell.open();
}
if let Some(at) = Self::graphics_page(page) {
self.graphics.show(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
@@ -2238,12 +2240,14 @@ impl OptionsUi {
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
_ => None,
}
}
@@ -2252,6 +2256,11 @@ impl OptionsUi {
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics screen's vanilla rows that step through its
/// resolutions: before each input that can press one.
fn graphics_framebuffer(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
@@ -2259,43 +2268,17 @@ impl OptionsUi {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::BindKey(name) => self.selected_key = Some(name),
shell::Output::Push(page) => self.push(page),
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
}
self.push(page);
}
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::graphics_ui::Action>,
) -> Vec<Event> {
use crate::graphics_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
// The Shaders page's Shader Packs...: the list of either kind, as the page
// opens it from anywhere.
Action::ShaderPacks => {
events.push(Event::ListShaderPacks);
s.shader_options = None;
events.push(Event::OpenShaderOptions);
self.push(Page::ShaderPacks);
}
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// What the controller screen's actions do to these options.
fn controller_events(
&mut self,
@@ -3704,11 +3687,6 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if Self::graphics_page(self.page()).is_some() {
self.graphics.binding = self.selected_key;
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.binding = self.selected_pad_action;
self.controller.draw(s, batch, atlas, cursor, self.in_world);
@@ -3813,22 +3791,16 @@ impl OptionsUi {
}
match &wd.kind {
WKind::Button { label, active, act } => {
if matches!(act, Act::Done) {
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
wd.x,
wd.y,
wd.w,
wd.h,
label,
*active,
hover,
);
} else {
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
}
ui::focused_button(
batch,
atlas,
[wd.x, wd.y, wd.w, wd.h],
label,
*active,
pointer_over,
is_focused,
matches!(act, Act::Done).then_some(crate::widgets::Action::Done),
);
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
@@ -4077,21 +4049,6 @@ impl OptionsUi {
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if Self::graphics_page(self.page()).is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let actions =
self.graphics
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
return self.graphics_events(s, actions);
}
if self.shell().is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
@@ -4103,6 +4060,8 @@ impl OptionsUi {
return self.bind_mouse(s, button);
}
let shift = self.shift;
self.framebuffer = framebuffer;
self.graphics_framebuffer();
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
shell.click(s, (x, y), size, drag, right, shift)
});
@@ -4244,6 +4203,11 @@ impl OptionsUi {
if wd.list && (y < top || y >= bottom) {
continue;
}
// AbstractWidget.mouseClicked rejects inactive buttons before the
// container can focus them.
if matches!(wd.kind, WKind::Button { active: false, .. }) {
return events;
}
// A click focuses the widget for its highlight only: vanilla's wheel
// steps a keyboard-focused widget from anywhere, not a clicked one.
self.focus = Some(focus_id(wd_idx, &wd));
@@ -4385,8 +4349,7 @@ impl OptionsUi {
let slider = self.dragging.take().is_some();
let (shader_slider, shader_pack) = self.shader.release();
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
let graphics_slider =
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
let graphics_slider = self.controller.release() | shader_slider | shell_slider;
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
@@ -4453,7 +4416,6 @@ impl OptionsUi {
/// wheel wherever the cursor is. Focus left by a click does not count
/// (`focus_from_keyboard`): honouring it toggled the clicked option on every
/// later wheel event, from anywhere on the screen, while the list never scrolled.
/// The graphics screen has its own wheel (`GraphicsUi::wheel`).
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4467,14 +4429,11 @@ impl OptionsUi {
if self.resource_error.is_some() {
return Vec::new();
}
self.graphics_framebuffer();
if let Some(shell) = self.shell_mut() {
let outputs = shell.wheel(s, size, cursor, amount);
return self.shell_events(s, outputs);
}
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return Vec::new();
@@ -4566,10 +4525,6 @@ impl OptionsUi {
shell.scroll(s, size, amount);
return;
}
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return;
@@ -4766,8 +4721,6 @@ impl OptionsUi {
if shell.typing() {
shell.type_text(text);
}
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
self.controller.type_text(text);
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
@@ -4806,9 +4759,6 @@ impl OptionsUi {
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
if Self::controller_page(self.page()).is_some() {
return self.controller.shortcut(shortcut, clipboard);
}
@@ -4925,26 +4875,13 @@ impl OptionsUi {
return Vec::new();
}
if Self::graphics_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, handled) = self.graphics.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.graphics_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
return events;
}
if handled {
return events;
}
return events;
}
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
self.size
} else {
MIN_KEY_GUI
};
self.graphics_framebuffer();
if let Some(shell) = self.shell_mut() {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
@@ -6119,6 +6056,104 @@ mod tests {
assert_eq!(s.file.fullscreen_resolution, None);
}
#[test]
fn pointer_cursor_leaves_a_clicked_option_while_its_focus_stays() {
use crate::widgets::{Cursor, take_cursor};
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let size = (480.0, 480.0);
let (widgets, top, bottom, _) = ui.layout(&s, size.0, size.1);
let button = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
active: true,
act: Act::Cycle(_),
..
}
) && (!wd.list || wd.y >= top && wd.y + wd.h <= bottom)
})
.expect("visible cycle button");
let over = (button.x + 1.0, button.y + 1.0);
ui.click(
&mut s,
&atlas,
over.0,
over.1,
size,
[480, 480],
false,
false,
);
ui.release();
assert!(
ui.focus.is_some(),
"the accepted click keeps vanilla's focus"
);
assert!(!ui.focus_from_keyboard);
let _ = take_cursor();
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, over);
assert_eq!(take_cursor(), Cursor::PointingHand);
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
assert_eq!(take_cursor(), Cursor::Arrow, "focus is not mouse hover");
assert!(
ui.focus.is_some(),
"moving the pointer does not clear focus"
);
ui.focus_from_keyboard = true;
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
assert_eq!(
take_cursor(),
Cursor::Arrow,
"keyboard focus is not mouse hover either"
);
}
#[test]
fn pointer_cursor_leaves_a_disabled_option_without_focusing_it() {
use crate::widgets::{Cursor, take_cursor};
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::KeyBinds);
let size = (480.0, 480.0);
let (widgets, top, bottom, _) = ui.layout(&s, size.0, size.1);
let button = widgets
.iter()
.find(|wd| {
matches!(wd.kind, WKind::Button { active: false, .. })
&& (!wd.list || wd.y >= top && wd.y + wd.h <= bottom)
})
.expect("visible disabled button");
let over = (button.x + 1.0, button.y + 1.0);
let _ = take_cursor();
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, over);
assert_eq!(take_cursor(), Cursor::NotAllowed);
assert!(
ui.click(
&mut s,
&atlas,
over.0,
over.1,
size,
[480, 480],
false,
false
)
.is_empty()
);
assert!(
ui.focus.is_none(),
"an inactive widget rejects the click before focus"
);
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
assert_eq!(take_cursor(), Cursor::Arrow);
}
#[test]
fn a_clicked_extras_tooltip_leaves_with_the_pointer() {
let atlas = atlas();
@@ -7634,7 +7669,7 @@ mod tests {
/// Packs... opens the pack list, listed afresh, and Done comes back to that page.
#[test]
fn the_graphics_shaders_page_opens_the_shader_pack_list() {
use crate::graphics_ui::{H, Item, Target};
use crate::graphics_ui::{H, Item};
use winit::keyboard::KeyCode;
let atlas = atlas();
let mut s = Settings::default();
@@ -7643,15 +7678,15 @@ mod tests {
..Default::default()
};
ui.show(Page::Graphics);
ui.graphics.open(crate::graphics_ui::Page::Shaders);
let packs = Target::Item(Item::Horizon(H::ShaderPacks));
ui.graphics.show(crate::graphics_ui::Page::Shaders);
let packs = Item::Horizon(H::ShaderPacks);
assert!(
(0..40).any(|_| {
ui.key(&mut s, &atlas, KeyCode::Tab, None);
ui.graphics.focus == Some(packs)
ui.graphics.focused_row() == Some(packs)
}),
"{:?}",
ui.graphics.focus
ui.graphics.focused()
);
let events = ui.key(&mut s, &atlas, KeyCode::Enter, None);
assert_eq!(ui.page(), Page::ShaderPacks);
@@ -7660,7 +7695,10 @@ mod tests {
assert!(events.contains(&Event::OpenShaderOptions), "{events:?}");
ui.back(&mut s);
assert_eq!(ui.page(), Page::Graphics);
assert_eq!(ui.graphics.page(), crate::graphics_ui::Page::Shaders);
assert_eq!(
ui.graphics.open_page(),
Some(crate::graphics_ui::Page::Shaders)
);
}
#[test]
@@ -7883,14 +7921,20 @@ mod tests {
#[test]
fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
use crate::graphics_ui::{H, Item, Target};
use crate::graphics_ui::{H, Item};
use winit::keyboard::KeyCode as K;
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Graphics);
ui.graphics.show(crate::graphics_ui::Page::Performance);
let size = (640.0, 360.0);
s.horizon.chunk_updates = 16;
ui.graphics.focus = Some(Target::Item(Item::Horizon(H::ChunkUpdates)));
ui.graphics.can_change_value = true;
let chunk_updates = Item::Horizon(H::ChunkUpdates);
// Tab onto it: the arrows change it.
while ui.graphics.focused_row() != Some(chunk_updates) {
shell::Hosted::key(&mut ui.graphics, &mut s, K::Tab, None, false, size);
}
assert!(ui.graphics.changes_value());
let before = ui.graphics.scroll_amount();
ui.wheel(&mut s, size, (320.0, 150.0), [1920, 1080], -1.0);
assert_eq!(s.horizon.chunk_updates, 16);
@@ -7920,29 +7964,21 @@ mod tests {
// 2. Horizon Graphics page through OptionsUi:
// Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
ui.show(Page::Graphics);
ui.graphics.open(crate::graphics_ui::Page::Performance);
ui.graphics.show(crate::graphics_ui::Page::Performance);
s.horizon.chunk_updates = 10;
let chunk_updates = crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates);
ui.size = size;
ui.framebuffer = [854, 480];
// Tab into controls
for _ in 0..80 {
ui.key(&mut s, &atlas, K::Tab, None);
if ui.graphics.focus
== Some(crate::graphics_ui::Target::Item(
crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates),
))
{
if ui.graphics.focused_row() == Some(chunk_updates) {
break;
}
}
assert_eq!(
ui.graphics.focus,
Some(crate::graphics_ui::Target::Item(
crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates)
))
);
assert!(ui.graphics.can_change_value);
assert_eq!(ui.graphics.focused_row(), Some(chunk_updates));
assert!(ui.graphics.changes_value());
// Right arrow steps ChunkUpdates to 11
let events = ui.key(&mut s, &atlas, K::ArrowRight, None);
@@ -7951,7 +7987,7 @@ mod tests {
// Space toggles can_change_value to false
ui.key(&mut s, &atlas, K::Space, None);
assert!(!ui.graphics.can_change_value);
assert!(!ui.graphics.changes_value());
// ArrowRight does not change ChunkUpdates when can_change_value is false
ui.key(&mut s, &atlas, K::ArrowRight, None);
@@ -0,0 +1,237 @@
//! The shared atlas's LabPBR maps (`_n`, `_s` beside an item's, entity's or block's texture),
//! for shader packs: a map the size of the atlas would be mostly empty (only some textures have
//! maps) and as large as the atlas itself, so each is a paged map instead. The atlas is cut into
//! 16x16 pages; only pages holding a mapped texture get one in the map's pool, found through a
//! page table at the map's start. Packs read the maps with the colour's coordinates, as for
//! terrain: the translator turns their reads into `hz_pagedTexture` (horizon-shaders,
//! `PAGED_READ`, which documents the layout this writes).
/// A page's side, in texels.
pub const PAGE: u32 = 16;
/// Texels before the page table: the atlas's size, and the map's value where it has none.
const HEADER: u32 = 2;
/// One paged map, RGBA8.
#[derive(Clone, Debug, PartialEq)]
pub struct PagedMap {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// A texture's maps: its place in the atlas (x, y, width, height, in texels) and its normal and
/// specular images at that size (RGBA8), each where it has one.
pub struct SpriteMaps {
pub rect: [u32; 4],
pub maps: [Option<Vec<u8>>; 2],
}
/// The normal and specular paged maps of an atlas of `size` texels, each `None` where no texture
/// has that map; `max_side` is the GPU's largest texture side.
pub fn build(size: (u32, u32), sprites: &[SpriteMaps], max_side: u32) -> [Option<PagedMap>; 2] {
[
(0, crate::sprites::FLAT_NORMAL),
(1, crate::sprites::NO_SPECULAR),
]
.map(|(kind, default)| {
let mapped = sprites
.iter()
.filter_map(|s| s.maps[kind].as_deref().map(|m| (s.rect, m)));
build_one(size, mapped, default, max_side)
})
}
fn build_one<'a>(
(width, height): (u32, u32),
sprites: impl Iterator<Item = ([u32; 4], &'a [u8])>,
default: [u8; 4],
max_side: u32,
) -> Option<PagedMap> {
let (pages_x, pages_y) = (width.div_ceil(PAGE), height.div_ceil(PAGE));
// Each atlas page's place in the pool, plus one (0: none).
let mut table = vec![0u32; (pages_x * pages_y) as usize];
let mut pool: Vec<Vec<u8>> = Vec::new();
let blank: Vec<u8> = default.repeat((PAGE * PAGE) as usize);
for ([x, y, w, h], map) in sprites {
if w == 0 || h == 0 || map.len() < (w * h * 4) as usize || x + w > width || y + h > height {
continue;
}
for py in y / PAGE..=(y + h - 1) / PAGE {
for px in x / PAGE..=(x + w - 1) / PAGE {
let entry = &mut table[(py * pages_x + px) as usize];
if *entry == 0 {
pool.push(blank.clone());
*entry = pool.len() as u32;
}
let page = &mut pool[*entry as usize - 1];
// The sprite's texels on this page.
let (x0, x1) = (x.max(px * PAGE), (x + w).min((px + 1) * PAGE));
let (y0, y1) = (y.max(py * PAGE), (y + h).min((py + 1) * PAGE));
for ty in y0..y1 {
let src = (((ty - y) * w + (x0 - x)) * 4) as usize;
let dst = (((ty - py * PAGE) * PAGE + (x0 - px * PAGE)) * 4) as usize;
let len = ((x1 - x0) * 4) as usize;
page[dst..dst + len].copy_from_slice(&map[src..src + len]);
}
}
}
}
if pool.is_empty() || pool.len() >= 1 << 24 {
return None;
}
let entries = HEADER + pages_x * pages_y;
// The narrowest map width (whole pages) that keeps it within the GPU's side.
let (side, rows, map_height) = [1024, 2048, 4096, 8192, 16384]
.into_iter()
.filter(|&s| s <= max_side)
.map(|s| {
let rows = entries.div_ceil(s).next_multiple_of(PAGE);
let per_row = s / PAGE;
(s, rows, rows + (pool.len() as u32).div_ceil(per_row) * PAGE)
})
.find(|&(_, _, h)| h <= max_side)?;
let mut pixels = vec![0u8; (side * map_height * 4) as usize];
let header = [
width as u8,
(width >> 8) as u8,
height as u8,
(height >> 8) as u8,
];
pixels[..4].copy_from_slice(&header);
pixels[4..8].copy_from_slice(&default);
for (i, &k) in table.iter().enumerate() {
let at = (HEADER as usize + i) * 4;
pixels[at..at + 4].copy_from_slice(&[k as u8, (k >> 8) as u8, (k >> 16) as u8, 255]);
}
let per_row = side / PAGE;
for (k, page) in pool.iter().enumerate() {
let k = k as u32;
let (ox, oy) = (k % per_row * PAGE, rows + k / per_row * PAGE);
for row in 0..PAGE {
let dst = (((oy + row) * side + ox) * 4) as usize;
let src = (row * PAGE * 4) as usize;
pixels[dst..dst + (PAGE * 4) as usize]
.copy_from_slice(&page[src..src + (PAGE * 4) as usize]);
}
}
Some(PagedMap {
width: side,
height: map_height,
pixels,
})
}
/// `map` (RGBA8, `mw` by `mh`) at `w` by `h`, nearest: a map drawn for a strip of animation
/// frames keeps its first, the colour's shape from its top.
pub fn fit(map: &[u8], (mw, mh): (u32, u32), (w, h): (u32, u32)) -> Option<Vec<u8>> {
if mw == 0 || mh == 0 || w == 0 || h == 0 || map.len() < (mw * mh * 4) as usize {
return None;
}
// The first frame of a strip as tall as the colour's frame is wide, scaled.
let frame_h = (u64::from(mw) * u64::from(h) / u64::from(w)).clamp(1, u64::from(mh)) as u32;
let mut out = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
let sy = (u64::from(y) * u64::from(frame_h) / u64::from(h)) as u32;
for x in 0..w {
let sx = (u64::from(x) * u64::from(mw) / u64::from(w)) as u32;
let at = ((sy * mw + sx) * 4) as usize;
out.extend_from_slice(&map[at..at + 4]);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// What `PAGED_READ` returns at texel (x, y) of the atlas.
fn read(map: &PagedMap, (width, height): (u32, u32), (x, y): (u32, u32)) -> [u8; 4] {
let texel = |at: u32| {
let at = (at * 4) as usize;
[0, 1, 2, 3].map(|c| map.pixels[at + c])
};
let at = |x: u32, y: u32| texel(y * map.width + x);
let h = texel(0);
assert_eq!(
(
u32::from(h[0]) | u32::from(h[1]) << 8,
u32::from(h[2]) | u32::from(h[3]) << 8
),
(width, height)
);
let pages = (width.div_ceil(PAGE), height.div_ceil(PAGE));
let e = texel(2 + (y / PAGE) * pages.0 + x / PAGE);
let k = u32::from(e[0]) | u32::from(e[1]) << 8 | u32::from(e[2]) << 16;
if k == 0 {
return texel(1);
}
let rows = (2 + pages.0 * pages.1)
.div_ceil(map.width)
.next_multiple_of(16);
let per_row = map.width / 16;
at(
(k - 1) % per_row * 16 + x % 16,
rows + (k - 1) / per_row * 16 + y % 16,
)
}
#[test]
fn mapped_textures_read_back_and_the_rest_reads_the_default() {
let size = (4096, 2048);
// A 20x20 texture across four pages, and one without a specular map.
let normal: Vec<u8> = (0..20 * 20)
.flat_map(|i: u32| [i as u8, (i >> 8) as u8, 7, 255])
.collect();
let sprites = [
SpriteMaps {
rect: [30, 40, 20, 20],
maps: [Some(normal.clone()), Some(vec![9; 20 * 20 * 4])],
},
SpriteMaps {
rect: [1000, 1000, 16, 16],
maps: [Some(vec![3; 16 * 16 * 4]), None],
},
];
let [normals, specular] = build(size, &sprites, 16384);
let (normals, specular) = (normals.unwrap(), specular.unwrap());
for y in 0..20 {
for x in 0..20 {
let i = ((y * 20 + x) * 4) as usize;
assert_eq!(read(&normals, size, (30 + x, 40 + y)), normal[i..i + 4]);
assert_eq!(read(&specular, size, (30 + x, 40 + y)), [9; 4]);
}
}
// The texels beside it on its pages, and pages without a map.
assert_eq!(read(&normals, size, (29, 40)), crate::sprites::FLAT_NORMAL);
assert_eq!(read(&normals, size, (2000, 5)), crate::sprites::FLAT_NORMAL);
assert_eq!(
read(&specular, size, (1005, 1005)),
crate::sprites::NO_SPECULAR
);
assert_eq!(read(&normals, size, (1005, 1005)), [3; 4]);
// Five pages: a table of 2 + 256 x 128 entries, 33 rows at 1024 wide (48, whole pages),
// then a row of pages.
assert_eq!((normals.width, normals.height), (1024, 64));
}
#[test]
fn nothing_mapped_makes_no_map() {
let sprites = [SpriteMaps {
rect: [0, 0, 16, 16],
maps: [None, None],
}];
assert_eq!(build((1024, 1024), &sprites, 16384), [None, None]);
}
#[test]
fn a_map_fits_its_texture() {
// A 2x map is sampled down; a strip of frames keeps its first.
let map: Vec<u8> = (0..4 * 8).flat_map(|i: u8| [i, 0, 0, 255]).collect();
let fitted = fit(&map, (4, 8), (2, 2)).unwrap();
assert_eq!(
fitted.chunks(4).map(|p| p[0]).collect::<Vec<_>>(),
[0, 2, 8, 10]
);
}
}
@@ -18,15 +18,33 @@ pub fn entity() -> LegacyRandom {
create()
}
/// `RandomSupport.SEED_UNIQUIFIER`'s start and step.
const UNIQUIFIER_START: i64 = 8_682_522_807_148_012;
const UNIQUIFIER_STEP: i64 = 1_181_783_497_276_652_981;
thread_local! {
/// This thread's uniquifier under `HORIZON_TEST_SEEDS`.
static THREAD_UNIQUIFIER: std::cell::Cell<i64> = const { std::cell::Cell::new(UNIQUIFIER_START) };
}
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
/// come out the same every run) the clock is left out, so the seeds repeat run
/// to run, in the order they are taken.
/// to run, in the order they are taken. Under `HORIZON_TEST_SEEDS` the clock is
/// left out too and each thread steps a uniquifier of its own, as the parity
/// oracles' agent makes vanilla do (docs/parity-workflow.md, "Randoms that
/// repeat"): the client's draws then don't shift the server thread's.
pub fn unique_seed() -> i64 {
use std::sync::atomic::{AtomicI64, Ordering};
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
static UNIQUIFIER: AtomicI64 = AtomicI64::new(UNIQUIFIER_START);
if test_seeds() {
return THREAD_UNIQUIFIER.with(|u| {
u.set(u.get().wrapping_mul(UNIQUIFIER_STEP));
u.get()
});
}
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
let next = loop {
let next = current.wrapping_mul(1_181_783_497_276_652_981);
let next = current.wrapping_mul(UNIQUIFIER_STEP);
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break next,
Err(v) => current = v,
@@ -47,6 +65,30 @@ fn still() -> bool {
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
}
/// `HORIZON_TEST_SEEDS=1`, a dev switch for parity harnesses, read once.
fn test_seeds() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
let on = std::env::var("HORIZON_TEST_SEEDS").is_ok_and(|v| v == "1");
if on {
log::warn!(
"HORIZON_TEST_SEEDS=1: unseeded randoms repeat run to run (parity harnesses only)"
);
}
on
})
}
/// A harness's sync step: the calling thread's uniquifier from now on. Only
/// under `HORIZON_TEST_SEEDS`, where the clock is left out.
pub fn set_thread_uniquifier(value: i64) -> Result<(), &'static str> {
if !test_seeds() {
return Err("setting the seed uniquifier needs HORIZON_TEST_SEEDS=1");
}
THREAD_UNIQUIFIER.with(|u| u.set(value));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -840,6 +840,7 @@ pub enum Opt {
PadRepeatInterval,
PadInvertX,
PadInvertY,
PadCraftOnServers,
PadToggleSneak,
PadMoveDeadzone,
PadLookDeadzone,
@@ -4,8 +4,9 @@
//!
//! A new screen implements [`Screen`], describing its rows from the settings, and [`Shell`] does
//! everything else: drawing, clicks, one scroll path, a controller's focus and its declared
//! buttons, keys and each row's description (`extras_ui` is the smallest). `graphics_ui`,
//! `controller_ui` and `shader_ui` still drive the parts below by hand.
//! buttons, keys and each row's description (`extras_ui` is the smallest; `graphics_ui` has
//! pages, sliders, costs and Undo). `controller_ui` and `shader_ui` still drive the parts below
//! by hand.
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
@@ -38,6 +39,15 @@ pub(crate) struct Columns {
const NOTE_LINES: usize = 2;
/// The height of a line of description.
const NOTE_LINE_H: f32 = 10.0;
/// The strip's text from its left edge, and its first line (the name) from its top.
const DOCK_INSET: f32 = 12.0;
const DOCK_NAME_Y: f32 = 8.0;
/// A cost's square before the name: its side, how far down it sits, and the name after it.
const COST_SQUARE: f32 = 4.0;
const COST_SQUARE_Y: f32 = 10.0;
const COST_NAME_X: f32 = 8.0;
/// What the strip keeps clear at its right of a name with its cost.
const COST_LINE_SLACK: f32 = 32.0;
/// A taller strip leaves the list at least this high.
const MIN_LIST_H: f32 = 60.0;
impl Columns {
@@ -745,7 +755,6 @@ pub(crate) enum Value {
Choice(String),
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
/// on the track sets it (a controller's focus does the same).
#[allow(dead_code)] // No screen on the shell has one yet; `controller_ui` will (#11487).
Slider {
position: f32,
steps: Option<u32>,
@@ -772,6 +781,10 @@ pub(crate) struct Row<R> {
pub(crate) chosen: Option<String>,
/// Can be used now (a setting of a switch that is off can't).
pub(crate) live: bool,
/// Why it can't be used now: shown in the strip, greyed, instead of what it does.
pub(crate) why: Option<String>,
/// What it costs, beside its name in the strip.
pub(crate) cost: Option<style::Cost>,
}
impl<R> Row<R> {
pub(crate) fn new(id: R, name: String, value: Value) -> Self {
@@ -782,6 +795,8 @@ impl<R> Row<R> {
about: String::new(),
chosen: None,
live: true,
why: None,
cost: None,
}
}
pub(crate) fn about(mut self, about: String) -> Self {
@@ -792,6 +807,16 @@ impl<R> Row<R> {
self.live = live;
self
}
/// Greyed, saying why, while there is a reason it can't be used.
pub(crate) fn blocked(mut self, why: Option<String>) -> Self {
self.live = why.is_none();
self.why = why;
self
}
pub(crate) fn cost(mut self, cost: style::Cost) -> Self {
self.cost = Some(cost);
self
}
/// Its whole caption ("Name: value"), for the narrator and the widget tree.
fn label(&self, value: Option<&str>) -> String {
match value {
@@ -885,6 +910,28 @@ pub(crate) trait Screen {
) -> Option<Vec<Output>> {
None
}
/// Whether the screen has Undo, which puts back what it changed (`undo`) since it opened.
fn undoes(&self) -> bool {
false
}
/// Undo: put back, from the settings as the screen opened (`before`), what it changes.
fn undo(&mut self, _s: &mut Settings, _before: &Settings) {}
/// Whether what a row did (its outputs) is a change Undo puts back.
fn changes(&self, outputs: &[Output]) -> bool {
changes_settings(outputs)
}
}
/// Whether `outputs` say a setting changed, or a key is being bound to one.
pub(crate) fn changes_settings(outputs: &[Output]) -> bool {
use crate::options_ui::Event;
let changed = |output: &Output| {
matches!(
output,
Output::BindKey(_) | Output::Event(Event::Changed | Event::SliderDrag)
)
};
outputs.iter().any(changed)
}
/// A fingerprint of `parts` for [`Screen::stamp`]: their `Debug` text, hashed as it is written,
@@ -958,6 +1005,9 @@ pub(crate) trait Hosted {
fn typing(&self) -> bool;
/// A key or the pad moved the focus away: the search field stops taking the keys.
fn blur_search(&mut self);
/// Nothing has the focus: a touch moved off it, say.
#[allow(dead_code)] // For #11846's `OptionsUi::touch_start`, which cleared Graphics' focus.
fn clear_focus(&mut self);
/// Text typed into the search field (the keyboard, a controller's on-screen keyboard).
fn type_text(&mut self, text: &str);
/// Ctrl+A/C/V/X in the search field.
@@ -970,10 +1020,11 @@ pub(crate) trait Hosted {
/// What the keyboard or a controller can focus.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Target<P, R> {
pub(crate) enum Target<P, R> {
Row(R),
Page(P),
Search,
Undo,
Done,
}
@@ -1022,6 +1073,10 @@ pub(crate) struct Shell<S: Screen> {
waiting: Option<&'static str>,
/// The rows as last built, and what for.
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
/// The settings as the screen found them, for Undo (`Screen::undoes`), and whether it
/// changed them since.
before: Option<Box<Settings>>,
changed: bool,
}
impl<S: Screen + Default> Default for Shell<S> {
fn default() -> Self {
@@ -1041,6 +1096,8 @@ impl<S: Screen + Default> Default for Shell<S> {
picked: None,
waiting: None,
built: None,
before: None,
changed: false,
}
}
}
@@ -1084,6 +1141,34 @@ impl<S: Screen> Shell<S> {
fn rebuild(&mut self) {
self.built = None;
}
/// A row did `outputs`: it reads anew, and Undo has something to put back if they changed it.
fn did(&mut self, outputs: &[Output]) {
self.rebuild();
// Only a screen with Undo has something for it to put back.
if self.screen.undoes() && self.screen.changes(outputs) {
self.changed = true;
}
}
/// Remember the settings before the first change, for Undo.
fn keep(&mut self, s: &Settings) {
if self.screen.undoes() && self.before.is_none() {
self.before = Some(Box::new(s.clone()));
}
}
/// Undo, gone once it has put the settings back.
fn undo(&mut self, s: &mut Settings) -> Vec<Output> {
if let Some(before) = self.before.take() {
self.screen.undo(s, &before);
self.before = Some(before);
}
self.changed = false;
self.rebuild();
vec![CLICK, Output::Event(crate::options_ui::Event::Changed)]
}
/// Open at `page`.
pub(crate) fn show(&mut self, page: S::Page) {
self.page = Some(page);
}
/// The screen's rows: the open page's or, while searching, every page's whose name or
/// description has the search in it, under their pages' names. The row being changed stays
/// whatever it reads now.
@@ -1211,14 +1296,14 @@ impl<S: Screen> Shell<S> {
};
self.dragging = Some(row.id);
self.screen.slide(s, row.id, position);
self.rebuild();
self.did(&[DRAGGED]);
vec![DRAGGED]
}
_ => {
self.can_change_value = false;
let mut outputs = vec![CLICK];
outputs.extend(self.screen.press(s, row.id, back));
self.rebuild();
self.did(&outputs);
outputs
}
}
@@ -1245,6 +1330,9 @@ impl<S: Screen> Shell<S> {
out.push((Target::Row(id), cols.row(y)));
}
}
if self.changed {
out.push((Target::Undo, cols.button(1)));
}
out.push((Target::Done, cols.button(0)));
out
}
@@ -1340,10 +1428,14 @@ impl<S: Screen> Shell<S> {
current: Option<S::Page>,
) {
let cols = &view.cols;
let [x, y, ..] = cols.dock;
let [x, y, w, _] = cols.dock;
let left = x + DOCK_INSET;
let top = y + DOCK_NAME_Y;
style::panel(batch, atlas, cols.dock);
let (name, about) = match described.map(|i| &view.rows[i]) {
Some(row) => (row.name.as_str(), row.described()),
let row = described.map(|i| &view.rows[i]);
let why = row.and_then(|row| row.why.as_deref().filter(|_| !row.live));
let (name, about) = match row {
Some(row) => (row.name.as_str(), why.unwrap_or(row.described())),
None => {
let Some(page) = view.pages.iter().find(|p| Some(p.0) == current) else {
return;
@@ -1351,11 +1443,30 @@ impl<S: Screen> Shell<S> {
(page.1.as_str(), page.2.as_str())
}
};
let name = Text::literal(name.to_owned());
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
match row.and_then(|row| row.cost) {
// Its cost in its colour, after a square of it.
Some(cost) => {
let line = format!("{name} · {}", lang::get(cost.label()));
let line = style::fit(atlas, &line, w - COST_LINE_SLACK);
let line = Text::literal(line).with_color(cost.rgb());
let square = [left, y + COST_SQUARE_Y, COST_SQUARE, COST_SQUARE];
style::fill(batch, atlas, square, style::cost_color(cost));
let after = left + COST_NAME_X;
batch.text_styled(atlas, &line, after, top, style::WHITE, true);
}
None => {
let name = Text::literal(name.to_owned());
batch.text_styled(atlas, &name, left, top, style::WHITE, true);
}
}
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
crate::widgets::record(about, cols.dock, false);
dock_note(batch, atlas, cols, about.to_owned(), style::GREY);
let color = if why.is_some() {
style::MUTED
} else {
style::GREY
};
dock_note(batch, atlas, cols, about.to_owned(), color);
}
}
impl<S: Screen + Default> Hosted for Shell<S> {
@@ -1372,6 +1483,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
waiting: Option<&'static str>,
) {
self.waiting = waiting;
self.keep(s);
let view = self.view(s, (batch.width, batch.height));
let cols = view.cols;
backdrop(batch, atlas, in_world);
@@ -1422,6 +1534,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
crate::widgets::focus_link(cols.search(), true, row);
}
self.draw_dock(batch, atlas, &view, described, current);
// Undo, over Done, only once there is something to undo.
if self.changed {
let undo = cols.button(1);
let label = Text::literal(lang::get("Undo"));
let flags = (self.shows_focus(Target::Undo), false);
crate::widgets::declare_action(crate::widgets::Action::Apply, undo, true);
foot_button(batch, atlas, undo, &label, cursor, flags);
}
let done = cols.button(0);
let label = Text::literal(lang::get("Done"));
let flags = (self.shows_focus(Target::Done), true);
@@ -1437,6 +1557,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
right: bool,
shift: bool,
) -> Vec<Output> {
self.keep(s);
let view = self.view(s, size);
let cols = view.cols;
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
@@ -1454,7 +1575,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
}
if let Some(row) = self.dragging {
self.screen.slide(s, row, cols.slider_at(x));
self.rebuild();
self.did(&[DRAGGED]);
return vec![DRAGGED];
}
return Vec::new();
@@ -1487,6 +1608,11 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.focus = Some(Target::Done);
return vec![CLICK, Output::Done];
}
if self.changed && at(cols.button(1)) {
// Undo goes away with the change it undid.
self.focus = None;
return self.undo(s);
}
for (i, page) in self.tabs(&view) {
if at(cols.page(i)) && self.has(page, &view) {
self.focus = Some(Target::Page(page));
@@ -1534,7 +1660,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
{
self.rebuild();
self.did(&outputs);
return outputs;
}
}
@@ -1557,6 +1683,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
size: (f32, f32),
) -> Vec<Output> {
use winit::keyboard::KeyCode as K;
self.keep(s);
if key == K::Escape {
self.can_change_value = false;
return vec![Output::Done];
@@ -1608,7 +1735,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
let width = view.cols.track().1 + 8.0;
let next = slider_step(position, steps, width, key == K::ArrowRight);
self.screen.slide(s, row.id, next);
self.rebuild();
self.did(&[DRAGGED]);
return vec![DRAGGED];
}
if matches!(
@@ -1644,6 +1771,11 @@ impl<S: Screen + Default> Hosted for Shell<S> {
Some(row) => self.activate(s, row, shift, None),
None => Vec::new(),
},
Some(Target::Undo) => {
// Undo goes away with the change it undid: Done has the focus.
self.focus = Some(Target::Done);
self.undo(s)
}
Some(Target::Done) => vec![CLICK, Output::Done],
Some(Target::Search) | None => Vec::new(),
}
@@ -1656,6 +1788,10 @@ impl<S: Screen + Default> Hosted for Shell<S> {
self.focus = None;
}
}
fn clear_focus(&mut self) {
self.focus = None;
self.can_change_value = false;
}
fn type_text(&mut self, text: &str) {
if !text.is_empty() {
self.search.insert_text(text);
@@ -1699,6 +1835,71 @@ impl<S: Screen> Shell<S> {
pub(crate) fn scroll_amount(&self) -> f32 {
self.scroll.amount
}
/// What the shell's own focus is on.
pub(crate) fn focused(&self) -> Option<Target<S::Page, S::Row>> {
self.focus
}
/// Put the focus there, as a key would.
pub(crate) fn set_focus(&mut self, focus: Option<Target<S::Page, S::Row>>) {
self.focus = focus;
self.keyed = true;
}
/// Left and Right move the focused slider.
pub(crate) fn changes_value(&self) -> bool {
self.can_change_value
}
/// Undo shows: the screen changed something since it opened.
pub(crate) fn changed(&self) -> bool {
self.changed
}
/// The scrollbar is held.
pub(crate) fn scroller_held(&self) -> bool {
self.scroll.held()
}
/// What the search field holds now, set as typing would.
pub(crate) fn set_search(&mut self, text: &str) {
self.search.set_value(text);
self.searched();
}
/// The page shown while nothing is searched, as opened or picked.
pub(crate) fn open_page(&self) -> Option<S::Page> {
self.page
}
/// The list's lines, in order, and its content's height.
pub(crate) fn lines_at(
&mut self,
s: &Settings,
size: (f32, f32),
) -> (Vec<Line<S::Page, usize>>, f32) {
let view = self.view(s, size);
(view.lines.clone(), view.content)
}
/// Each row of the list, in order, with its page.
pub(crate) fn listed(&mut self, s: &Settings, size: (f32, f32)) -> Vec<(S::Page, S::Row)> {
let view = self.view(s, size);
let lines = view.lines.iter();
lines
.filter_map(|l| Some((l.page, view.rows[l.setting?].id)))
.collect()
}
/// The page the plate is headed with.
pub(crate) fn current_page(&mut self, s: &Settings, size: (f32, f32)) -> Option<S::Page> {
let view = self.view(s, size);
self.current(&view)
}
/// Whether `row` is all in the list's view now.
pub(crate) fn in_view(&mut self, s: &Settings, size: (f32, f32), row: S::Row) -> bool {
let view = self.view(s, size);
let cols = view.cols;
let mut placed = self.placed(&view);
placed.any(|(r, y)| r.id == row && y >= cols.top && y + style::ROW <= cols.bottom)
}
/// Whether Tab reaches `row` now.
pub(crate) fn tab_reaches(&mut self, s: &Settings, size: (f32, f32), row: S::Row) -> bool {
let view = self.view(s, size);
let focusables = self.focusables(&view, false);
focusables.iter().any(|(t, _)| *t == Target::Row(row))
}
/// The row the shell's own focus is on.
pub(crate) fn focused_row(&self) -> Option<S::Row> {
match self.focus {
@@ -1740,6 +1941,7 @@ mod tests {
const FIRST_FILLER: u32 = 10;
const FILLERS: u32 = 40;
const GREY: u32 = 100;
const WHY: &str = "Not here.";
impl Screen for Probe {
type Page = u8;
type Row = u32;
@@ -1768,7 +1970,9 @@ mod tests {
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
.about("Picks a colour.".to_owned())
},
Row::new(GREY, "Greyed".to_owned(), Value::Press).live(false),
Row::new(GREY, "Greyed".to_owned(), Value::Press)
.about("Does a thing.".to_owned())
.blocked(Some(WHY.to_owned())),
];
rows.extend((0..FILLERS).map(|i| {
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
@@ -1792,13 +1996,21 @@ mod tests {
},
]
}
fn press(&mut self, _: &mut Settings, row: u32, back: bool) -> Vec<Output> {
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
self.pressed.push((row, back));
if row == CHOICE {
self.choice = (self.choice + 1) % CHOICES.len();
match row {
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
FIRST_FILLER => s.fov += 1.0,
_ => {}
}
vec![CHANGED]
}
fn undoes(&self) -> bool {
true
}
fn undo(&mut self, s: &mut Settings, before: &Settings) {
s.fov = before.fov;
}
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
self.slider = position;
}
@@ -1898,6 +2110,85 @@ mod tests {
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
// Nothing changed: there is no Undo to press.
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(s.fov, 71.0);
// Another option changed meanwhile isn't the screen's to put back.
s.chat_opacity = 0.25;
assert_eq!(
shell.click(&mut s, on_undo, SIZE, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
shell.click(&mut s, at, SIZE, false, false, false);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focus == Some(Target::Undo)
});
assert!(reached);
assert_eq!(
shell.key(&mut s, K::Enter, None, false, SIZE),
vec![CLICK, CHANGED]
);
assert_eq!(s.fov, 70.0);
assert_eq!(shell.focus, Some(Target::Done));
}
#[test]
fn the_strip_says_why_a_greyed_row_cant_be_used() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
let size = (batch.width, batch.height);
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
shell.draw(&s, batch, &atlas, at, false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
let dock = Columns::of(size.0, size.1).dock;
let labelled = crate::widgets::last_labelled();
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
found.map(|w| w.label.clone())
};
let grey = middle_of(&mut shell, GREY, size);
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
let choice = middle_of(&mut shell, CHOICE, size);
assert_eq!(
strip(&mut batch, &mut shell, choice).as_deref(),
Some(CHOICES[0].1)
);
}
#[test]
fn growing_the_window_keeps_the_scroll_within_the_list() {
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
shell.scroll(&s, SIZE, -1000.0);
assert!(shell.scroll.amount > 0.0);
let tall = (SIZE.0, 4000.0);
let view = shell.view(&s, tall);
assert_eq!(shell.scroll.amount, 0.0);
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
assert!(first >= view.cols.top);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
@@ -20,6 +20,7 @@ use std::{
pub const MASTER: usize = 0;
pub const MUSIC: usize = 1;
pub const RECORDS: usize = 2;
pub const WEATHER: usize = 3;
pub const NEUTRAL: usize = 6;
pub const PLAYERS: usize = 7;
pub const AMBIENT: usize = 8;
@@ -77,7 +78,7 @@ impl SoundEntity {
}
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Ticking {
/// Not tickable.
No,
@@ -112,6 +113,24 @@ pub enum Ticking {
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
/// `DirectionalSoundInstance`: ten blocks from the camera toward
/// `Vec3.directionFromRotation(x_angle, y_angle)`.
Directional { x_angle: f32, y_angle: f32 },
}
/// `DirectionalSoundInstance.setPosition`: the camera plus
/// `Vec3.directionFromRotation(xAngle, yAngle).scale(10.0)`.
fn directional_position(camera: [f64; 3], x_angle: f32, y_angle: f32) -> [f64; 3] {
use horizon_core::geometry::trig;
let to_rad = std::f32::consts::PI / 180.0;
let y = -y_angle * to_rad - std::f32::consts::PI;
let (y_cos, y_sin) = (trig::cos(f64::from(y)), trig::sin(f64::from(y)));
let x = -x_angle * to_rad;
let (x_cos, x_sin) = (-trig::cos(f64::from(x)), trig::sin(f64::from(x)));
[
camera[0] + f64::from(y_sin * x_cos) * 10.0,
camera[1] + f64::from(x_sin) * 10.0,
camera[2] + f64::from(y_cos * x_cos) * 10.0,
]
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -170,6 +189,8 @@ pub struct TickContext {
pub player_fall_flying: bool,
/// The mob the player rides, when it is not a minecart.
pub ridden: Option<RiddenSound>,
/// `mainCamera().position()`.
pub camera_position: [f64; 3],
}
/// `AbstractSoundInstance` and its subclasses' fields.
#[derive(Clone, Debug)]
@@ -588,6 +609,31 @@ impl SoundInstance {
s.ticking = Ticking::Elytra { time: 0 };
s
}
/// `new DirectionalSoundInstance(event, source, random, camera, xAngle,
/// yAngle)`: volume and pitch 1, heard everywhere alike.
pub fn directional(
id: &str,
source: usize,
random: LegacyRandom,
camera: [f64; 3],
x_angle: f32,
y_angle: f32,
) -> Self {
let mut s = Self::new(
id,
source,
1.0,
1.0,
random,
false,
0,
Attenuation::None,
directional_position(camera, x_angle, y_angle),
false,
);
s.ticking = Ticking::Directional { x_angle, y_angle };
s
}
pub fn tickable(&self) -> bool {
self.ticking != Ticking::No
}
@@ -762,6 +808,12 @@ impl SoundInstance {
self.volume = 1.0;
self.pitch = 1.0;
}
Ticking::Directional { x_angle, y_angle } => {
// DirectionalSoundInstance.tick: setPosition.
let (x_angle, y_angle) = (*x_angle, *y_angle);
self.position = directional_position(ctx.camera_position, x_angle, y_angle);
self.attenuation = Attenuation::None;
}
Ticking::GuardianAttack { guardian } => {
let id = *guardian;
// The client's guardian never has a target of its own, so
+131 -21
View File
@@ -274,6 +274,7 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
opaque_ice: bool,
translucent_layers: Arc<[bool]>,
leaf_culling: LeafCullingMode,
}
@@ -380,6 +381,33 @@ impl MeshPool {
}
}
/// The chunks `wanted` in the order they decorate: by rank, lowest first. No
/// two share a rank, so only the chunks without one yet need their distance:
/// those no ticket reached (forced and extra areas) are ranked after every
/// other, nearest first. The pass that pops them stops once the workers are
/// busy, usually long before the last, so a heap orders only what it takes. A
/// server with players spread over the map holds tens of thousands of proto
/// chunks; measuring and sorting them all every tick took a fifth of its tick.
fn decoration_queue(
order: &mut DecorationOrder,
wanted: impl Iterator<Item = (i32, i32)>,
distance: impl Fn(i32, i32) -> i64,
) -> std::collections::BinaryHeap<std::cmp::Reverse<(u64, i32, i32)>> {
let mut ranked = Vec::new();
let mut fresh = Vec::new();
for (x, z) in wanted {
match order.rank(x, z) {
Some(rank) => ranked.push(std::cmp::Reverse((rank, x, z))),
None => fresh.push((distance(x, z), x, z)),
}
}
fresh.sort_unstable();
for (_, x, z) in fresh {
ranked.push(std::cmp::Reverse((order.rank_or_append(x, z), x, z)));
}
std::collections::BinaryHeap::from(ranked)
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
/// half way through a send, and a worker descheduled there on a busy machine
@@ -533,6 +561,8 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// Draw regular ice opaque in the solid pass (`Mesher::opaque_ice`).
pub opaque_ice: bool,
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
/// translucent`), by layer; empty without one.
pub translucent_layers: Arc<[bool]>,
@@ -563,6 +593,9 @@ pub struct Streaming {
pub extra: Vec<((i32, i32), i32)>,
/// `extra`'s squares as a grid, made again when `extra` changes.
cover: Option<ExtraCover>,
/// Set while `update` runs, after it refreshed `cover`: nothing else can
/// change `extra` meanwhile, so `cover` answers without comparing them.
cover_current: bool,
/// Mesh the sections for drawing (false on a server thread).
pub mesh: bool,
/// The pregeneration band (`set_band`).
@@ -997,6 +1030,7 @@ impl<G: ChunkGenerator> Jobs<G> {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
opaque_ice: settings.opaque_ice,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
@@ -1111,6 +1145,7 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: Arc::from([]),
leaf_culling: LeafCullingMode::Off,
light_tables,
@@ -1123,6 +1158,7 @@ impl Streaming {
relight_resent: false,
extra: Vec::new(),
cover: None,
cover_current: false,
mesh: true,
band: None,
band_ranked: None,
@@ -1263,6 +1299,7 @@ impl Streaming {
self.meshed.remove(&(x, z));
self.lighting.remove(&(x, z));
world.dirty_sections.take_column(x, z);
world.entities_pending.remove(&(x, z));
chunks.extend(world.chunks.remove(&(x, z)));
}
chunks
@@ -1403,6 +1440,7 @@ impl Streaming {
.block_names
.extend(std::mem::take(&mut chunk.block_names));
let entities = std::mem::take(&mut chunk.entities);
world.entities_pending.insert((chunk.x, chunk.z));
if !entities.is_empty() {
world.loaded_entities.push(((chunk.x, chunk.z), entities));
}
@@ -1626,6 +1664,7 @@ impl Streaming {
return (uploads, Vec::new());
}
ExtraCover::refresh(&mut self.cover, &self.extra);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
// column can stay a little past the edge without anyone noticing.
@@ -1734,6 +1773,7 @@ impl Streaming {
if let Some(chunk) = world.chunks.remove(key) {
removed.push(chunk);
}
world.entities_pending.remove(key);
self.lit.remove(key);
self.meshed.remove(key);
self.lighting.remove(key);
@@ -1834,7 +1874,7 @@ impl Streaming {
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
band: self.band.as_ref(),
};
let (protos, generating) = (&self.protos, &self.generating);
@@ -1866,29 +1906,24 @@ impl Streaming {
})
})
};
let keys: Vec<(i32, i32)> = self
.protos
.iter()
.filter(|(k, p)| p.is_some() && wants(self, k.0, k.1))
.map(|(k, _)| *k)
.collect();
let mut wanted: Vec<(u64, i64, i32, i32)> = keys
.into_iter()
.map(|(x, z)| (0, self.distance(x, z), x, z))
.collect();
// Chunks no ticket reached (forced and extra areas) go last, nearest first.
wanted.sort_unstable_by_key(|w| w.1);
for w in &mut wanted {
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
}
wanted.sort_unstable();
// Out for the call: ranking reads the streamer's ranges.
let mut order = std::mem::take(&mut self.decoration_order);
let mut wanted = decoration_queue(
&mut order,
self.protos
.iter()
.filter(|&(&(x, z), proto)| proto.is_some() && wants(self, x, z))
.map(|(&k, _)| k),
|x, z| self.distance(x, z),
);
self.decoration_order = order;
// Most wanted chunks are blocked, and the same neighbours are
// asked about for each of them: what `waits_for` says is kept for
// the pass (nothing it reads changes in it but `decorated`, which
// updates the entry). Without it, scheduling at render distance 32
// took most of the thread that runs it while the world loaded.
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
for (_, _, x, z) in wanted {
while let Some(std::cmp::Reverse((_, x, z))) = wanted.pop() {
if self.decorating >= workers * 2 {
break;
}
@@ -1993,6 +2028,7 @@ impl Streaming {
// A server draws nothing: its sections are never meshed.
if !self.mesh {
world.dirty_sections.clear();
self.cover_current = false;
return (uploads, removed);
}
// Meshing once the column and its neighbours are lit, and the column
@@ -2013,7 +2049,7 @@ impl Streaming {
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
band: self.band.as_ref(),
};
self.waiting.retain(|&(x, sy, z)| {
@@ -2093,6 +2129,7 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
opaque_ice: self.opaque_ice,
translucent_layers: self.translucent_layers.clone(),
leaf_culling: self.leaf_culling,
},
@@ -2100,6 +2137,7 @@ impl Streaming {
);
}
}
self.cover_current = false;
(uploads, removed)
}
fn schedule_generation(&mut self, world: &World, workers: usize) {
@@ -2110,7 +2148,7 @@ impl Streaming {
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
band: self.band.as_ref(),
},
world,
@@ -2244,7 +2282,7 @@ impl Streaming {
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
band: self.band.as_ref(),
}
}
@@ -2615,6 +2653,20 @@ impl ExtraCover {
) -> Option<&'a ExtraCover> {
cover.as_ref().filter(|c| c.extra == extra)
}
/// [`Self::matching`], told whether `cover` is known to match: the
/// comparison reads every area, and `update` asks for each column it
/// looks at (a server holds an area per player).
fn current<'a>(
cover: &'a Option<ExtraCover>,
extra: &[((i32, i32), i32)],
known: bool,
) -> Option<&'a ExtraCover> {
if known {
debug_assert!(cover.as_ref().is_none_or(|c| c.extra == extra));
return cover.as_ref();
}
Self::matching(cover, extra)
}
/// Rebuild the coverage index if `extra` changed; none for one area or none
/// (a scan is as quick).
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
@@ -3173,6 +3225,64 @@ static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock
mod tests {
use super::*;
/// The queue hands out the wanted chunks in the order the full sort did
/// (every chunk measured, sorted by distance, the new ones ranked in that
/// order, then all sorted by rank), and ranks the new ones the same way,
/// whatever order the chunks are listed in.
#[test]
fn decoration_queue_pops_in_the_order_a_full_sort_gives() {
// Distinct for every chunk, so the full sort has one answer.
let distance = |x: i32, z: i32| i64::from(x) * 100_000 + i64::from(z);
let full_sort = |order: &mut DecorationOrder, wanted: &[(i32, i32)]| {
let mut w: Vec<(u64, i64, i32, i32)> = wanted
.iter()
.map(|&(x, z)| (0, distance(x, z), x, z))
.collect();
w.sort_unstable_by_key(|w| w.1);
for w in &mut w {
w.0 = order.rank_or_append(w.2, w.3);
}
w.sort_unstable();
w.into_iter().map(|w| (w.2, w.3)).collect::<Vec<_>>()
};
let ranked = |order: &mut DecorationOrder| {
order.reassign((0, 0), 6, |x, z| (x + z).rem_euclid(5) == 0);
};
let (mut fast, mut full) = (DecorationOrder::default(), DecorationOrder::default());
ranked(&mut fast);
ranked(&mut full);
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut shuffled = |mut keys: Vec<(i32, i32)>| {
for i in (1..keys.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
keys.swap(i, (seed % (i as u64 + 1)) as usize);
}
keys
};
// Some of the player's ranked chunks, and an extra area's that no
// ticket reached; the second time, more of the area, the first part
// ranked by then.
let mut wanted: Vec<(i32, i32)> = (-6i32..=6)
.flat_map(|x| (-6..=6).map(move |z| (x, z)))
.filter(|&(x, z)| (x * 7 + z).rem_euclid(3) != 0)
.collect();
wanted.extend((20..=24).flat_map(|x| (-3..=3).map(move |z| (x, z))));
for round in 0..2 {
if round == 1 {
wanted.extend((25..=27).flat_map(|x| (-3..=3).map(move |z| (x, z))));
}
let wanted = shuffled(wanted.clone());
let mut queue = decoration_queue(&mut fast, wanted.iter().copied(), distance);
let popped: Vec<(i32, i32)> = std::iter::from_fn(|| queue.pop())
.map(|std::cmp::Reverse((_, x, z))| (x, z))
.collect();
assert_eq!(popped, full_sort(&mut full, &wanted), "round {round}");
assert_eq!(fast.ordered(), full.ordered(), "round {round}");
}
}
#[test]
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
let mut old = Chunk::empty(2, -3);
+199 -63
View File
@@ -40,7 +40,10 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -199,26 +202,73 @@ fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
// The terrain stages' values, as the code reads them. What crosses from the vertex stage to the
// fragment stage is `TerrainWire`: the same values in fewer interpolant locations.
struct TerrainOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) color: vec4<f32>,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
clip: vec4<f32>,
uv: vec2<f32>,
layer: u32,
color: vec4<f32>,
spherical: f32,
cylindrical: f32,
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
@location(5) @interpolate(flat) emission: u32,
emission: u32,
// HDR lighting: block and sky light, as levels 0 to 15.
@location(11) scene: vec2<f32>,
@location(6) @interpolate(flat) fade: u32,
scene: vec2<f32>,
fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@location(7) local: vec3<f32>,
local: vec3<f32>,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
visibility: f32,
// The camera-relative height, for the water mirror's cut at the plane.
@location(9) height: f32,
@location(10) @interpolate(flat) emissive_layer: u32,
height: f32,
emissive_layer: u32,
}
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
// components are used): on an RX 580 the 12 cost 3.2 ns per quad and these 5 cost 1.6. Every
// component is still interpolated on its own (and the integers stay flat), so each pixel gets
// the very values the separate locations gave it.
struct TerrainWire {
@builtin(position) clip: vec4<f32>,
// The colour.
@location(0) a: vec4<f32>,
// `local` and `visibility`.
@location(1) b: vec4<f32>,
// `uv` and `scene`.
@location(2) c: vec4<f32>,
// `spherical`, `cylindrical` and `height`.
@location(3) d: vec4<f32>,
// `layer`, `emission`, `fade` and `emissive_layer`.
@location(4) @interpolate(flat) e: vec4<u32>,
}
fn to_wire(o: TerrainOut) -> TerrainWire {
var w: TerrainWire;
w.clip = o.clip;
w.a = o.color;
w.b = vec4(o.local, o.visibility);
w.c = vec4(o.uv, o.scene);
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
return w;
}
fn from_wire(w: TerrainWire) -> TerrainOut {
var o: TerrainOut;
o.clip = w.clip;
o.color = w.a;
o.local = w.b.xyz;
o.visibility = w.b.w;
o.uv = w.c.xy;
o.scene = w.c.zw;
o.spherical = w.d.x;
o.cylindrical = w.d.y;
o.height = w.d.z;
o.layer = w.e.x;
o.emission = w.e.y;
o.fade = w.e.z;
o.emissive_layer = w.e.w;
return o;
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -333,9 +383,9 @@ fn fade_code(w: f32) -> u32 {
@vertex fn vs_terrain(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
// index list. A section's vertices fill whole 256-vertex blocks of its page:
@@ -346,10 +396,10 @@ fn fade_code(w: f32) -> u32 {
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Solid terrain draws in two passes, each over its own part of every mesh's quads
// (`mesher::Part`): first the quads with no cutout that are not fading, through
@@ -370,38 +420,38 @@ fn left_out() -> TerrainOut {
@vertex fn vs_terrain_opaque(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_cutout(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
@location(5) origin: vec4<f32>) -> TerrainWire {
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_flat_opaque(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
@vertex fn vs_terrain_flat_cutout(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
) -> TerrainWire {
let origin = slot_origins[block_slots[vertex >> 8u]];
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w));
}
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
@@ -411,6 +461,12 @@ fn left_out() -> TerrainOut {
// Binding 19: visbuffer.wgsl, in the same module, takes 2 to 18 of its own group 1.
@group(1) @binding(19) var<storage, read> arena_page: array<vec2<u32>>;
const PACKED_BASE: u32 = 0x10000000u;
// Far tiles' own compact layout (`lod/packed.rs`), read apart from a section's by the base
// its draws use: from FAR_BASE on, a corner of the quad whose far record is at that slot.
const FAR_BASE: u32 = 0x20000000u;
// `TerrainVertex::tiled`: a far tile's UVs are in blocks from the face's corner, this many
// fixed-point steps each (`mesher::TILED_UV_STEPS`).
const TILED_UV_STEPS: f32 = 32.0;
// A vertex's words: position bits, then UV, layer with light, colour.
struct ArenaCorner {
p: vec3<u32>,
@@ -471,6 +527,39 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
// `lod::packed::Face::vertices`: corner `corner` of the far record whose words are lo (0 to
// 2) and hi (3 to 5). A far quad is a rectangle in an axis-aligned plane with one colour,
// one light and one layer, so the record holds the rectangle and this rebuilds the four
// corners and the UVs `lod::mesh::emit_quad` derived from them. The two have to agree, and
// `lod::mesh`'s `every_quad_the_mesher_emits_packs_and_comes_back` is what holds them to it.
fn far_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let axis = hi.z & 3u;
let reversed = (hi.z >> 2u) & 1u;
let plane = f32(bitcast<i32>(lo.z));
let u0 = f32(bitcast<i32>(hi.x));
let v0 = f32(bitcast<i32>(hi.y));
let u1 = u0 + f32((hi.z >> 3u) & 0x7ffu);
let v1 = v0 + f32((hi.z >> 14u) & 0x7ffu);
// `points.reverse()`, which is what the mesher does for Down, South and East.
let c = select(corner, 3u - corner, reversed == 1u);
let far_v = select(v0, v1, c <= 1u);
// Axis X runs its u the other way round, as that function's `points` do.
let first = c == 0u || c == 3u;
let far_u = select(select(u1, u0, first), select(u0, u1, first), axis == 2u);
// 0: the plane is y, u is x and v is z. 1: the plane is z, u is x, v is y.
// 2: the plane is x, u is z and v is y.
var pos = vec3(far_u, plane, far_v);
if axis == 1u {
pos = vec3(far_u, far_v, plane);
} else if axis == 2u {
pos = vec3(plane, far_v, far_u);
}
// The UV is `[u, -v]` of the face counted from its corner, which is `[u0, -v1]`: every
// coordinate is whole, so the mesher's `floor` of the least corner is the corner itself.
let uv = vec2<u32>(vec2(far_u - u0, v1 - far_v) * TILED_UV_STEPS);
// Word 1 is the layer with the light, word 0 the colour, as a vertex holds them.
return ArenaCorner(bitcast<vec3<u32>>(pos), vec3(uv.x | (uv.y << 16u), lo.y, lo.x));
}
// A solid draw's vertex `vertex`, and the slot its block is found by (`block_slots`, 256
// slots a block): the vertex's own, or its quad's record's. `packed`: it has a record.
struct SectionCorner {
@@ -482,6 +571,18 @@ fn section_corner(vertex: u32) -> SectionCorner {
if vertex < PACKED_BASE {
return SectionCorner(arena_vertex(vertex), vertex, false);
}
if vertex >= FAR_BASE {
let far_slot = (vertex - FAR_BASE) >> 2u;
let a = arena_page[far_slot * 3u];
let b = arena_page[far_slot * 3u + 1u];
let c = arena_page[far_slot * 3u + 2u];
// `FULL`: the quad's four vertices, word 0 slots on, as a section's spill.
if (c.y & 0x80000000u) != 0u {
return SectionCorner(arena_vertex(far_slot + a.x + (vertex & 3u)), far_slot, true);
}
return SectionCorner(far_corner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y), vertex & 3u),
far_slot, true);
}
let slot = (vertex - PACKED_BASE) >> 2u;
let corner = vertex & 3u;
let a = arena_page[slot * 3u];
@@ -511,37 +612,37 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
unorm8x4(c.r.z), light, origin.xyz, origin.w);
}
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
return corner_vertex(section_corner(vertex).c, origin);
@location(5) origin: vec4<f32>) -> TerrainWire {
return to_wire(corner_vertex(section_corner(vertex).c, origin));
}
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
@location(5) origin: vec4<f32>) -> TerrainWire {
let s = section_corner(vertex);
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
@location(5) origin: vec4<f32>) -> TerrainWire {
let s = section_corner(vertex);
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
return corner_vertex(s.c, origin);
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainWire {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
return to_wire(corner_vertex(s.c, origin));
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
@@ -620,11 +721,13 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_solid(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
return solid_color(in);
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_opaque(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
@@ -635,7 +738,8 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_solid_mirror(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if in.height < globals.fog_ends.w { discard; }
return solid_color(in);
}
@@ -656,7 +760,15 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
return solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv));
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
}
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
// light, alpha 1.
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return finished;
}
// `solid_finish` with the UV's screen gradients given (`sprite_at`).
fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<f32>,
@@ -703,7 +815,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
@fragment fn fs_translucent_mark(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
@@ -716,11 +829,26 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// Water, glass and other see-through faces draw whole as they fade in, the
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
// pixels showed the layer behind through the one in front, twice the water.
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
@fragment fn fs_translucent(wire: TerrainWire) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.01 { discard; }
return apply_fog(color, in.spherical, in.cylindrical);
let finished = apply_fog(color, in.spherical, in.cylindrical);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
// The surface's own light, whatever its texel's alpha: what is behind it is light already.
// (The marked frame keeps one mark a pixel, so there `fs_translucent_mark` skips clear
// texels to leave what is behind them its own.)
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return finished;
}
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
@@ -786,7 +914,8 @@ struct NativeModelOut {
+ max(0.0, dot(light1, in.normal))) * 0.6 + 0.4);
color = vec4(color.rgb * shade, color.a);
}
return apply_fog(color, in.spherical, in.cylindrical);
let finished = apply_fog(color, in.spherical, in.cylindrical);
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
@@ -919,12 +1048,19 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
}
return color;
}
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return vec4(color, mark);
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
return sky_finished(sky_along_ray(view_ray(in.ndc)));
}
@fragment fn fs_sky_base(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray_base(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
return sky_finished(sky_along_ray_base(view_ray(in.ndc)));
}
// The sunrise/sunset fan as geometry: the clip position is projected by the
// CPU with the sky's unjittered view, and the apex weight (1 at the apex, 0 at
@@ -937,7 +1073,7 @@ struct SunriseOut {
return SunriseOut(clip, weight);
}
@fragment fn fs_sunrise(in: SunriseOut) -> @location(0) vec4<f32> {
return vec4(globals.sunrise.rgb, in.weight * globals.sunrise.a);
return vec4(sky_finished(globals.sunrise.rgb).rgb, in.weight * globals.sunrise.a);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@@ -0,0 +1,4 @@
//! On-screen touch controls for touch-screen devices (Android first). Nothing
//! here runs unless a touch surface is in use.
pub mod layout;
pub use layout::{Control, Mode, Placement, Settings};
@@ -0,0 +1,429 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/touch/layout.rs`), written by Vasysik and the
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
// same MIT license as Horizon (LICENSE at the repository root).
//! The touch layout: which on-screen controls exist, where each one sits for
//! each control scheme, and the player's saved choices. Coordinates are
//! normalized to the surface; sizes are fractions of its height, so a button
//! stays square on wide phones and on tablets. Nothing here reads input or
//! draws: this is the file the touch controls and their editor share.
use serde::{Deserialize, Serialize};
use std::path::Path;
/// How the player moves and aims.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
/// A joystick moves; attack and use act on the crosshair.
#[default]
Crosshair,
/// A joystick moves; tapping the world attacks or uses what was tapped.
Tap,
/// A D-pad moves; tapping the world acts on it (or the crosshair, split).
Dpad,
}
impl Mode {
pub const ALL: [Self; 3] = [Self::Crosshair, Self::Tap, Self::Dpad];
/// The four schemes a settings screen offers: mode, `split_controls`, label.
pub const SCHEMES: [(Self, bool, &'static str); 4] = [
(Self::Crosshair, false, "Joystick & aim crosshair"),
(Self::Tap, false, "Joystick & tap to interact"),
(Self::Dpad, false, "D-Pad & tap to interact"),
(Self::Dpad, true, "D-Pad & aim crosshair"),
];
/// This mode's row in [`Settings::layouts`].
pub fn index(self) -> usize {
self as usize
}
}
/// The built-in controls. The order is the index into a layout row and into
/// `button_visibility`, so new controls go at the end.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Control {
Move,
Sprint,
Jump,
Sneak,
Attack,
Use,
Chat,
Pause,
Settings,
Inventory,
Debug,
Perspective,
Offhand,
Shield,
}
/// How many built-in controls a layout row holds.
pub const CONTROLS: usize = Control::ALL.len();
impl Control {
pub const ALL: [Self; 14] = [
Self::Move,
Self::Sprint,
Self::Jump,
Self::Sneak,
Self::Attack,
Self::Use,
Self::Chat,
Self::Pause,
Self::Settings,
Self::Inventory,
Self::Debug,
Self::Perspective,
Self::Offhand,
Self::Shield,
];
/// The name the layout editor shows.
pub fn name(self) -> &'static str {
match self {
Self::Move => "Movement",
Self::Sprint => "Sprint",
Self::Jump => "Jump / fly up",
Self::Sneak => "Sneak / fly down",
Self::Attack => "Attack / mine",
Self::Use => "Use / place",
Self::Chat => "Chat",
Self::Pause => "Pause",
Self::Settings => "Touch settings",
Self::Inventory => "Inventory",
Self::Debug => "Debug overlay (F3)",
Self::Perspective => "Camera perspective (F5)",
Self::Offhand => "Swap hands",
Self::Shield => "Raise shield",
}
}
}
/// Where one control sits: its centre (0..1 of the surface), its size (a
/// fraction of the surface height) and its opacity.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Placement {
pub center: [f32; 2],
pub size: f32,
pub opacity: f32,
}
impl Placement {
pub const fn new(x: f32, y: f32, size: f32) -> Self {
Self {
center: [x, y],
size,
opacity: 0.65,
}
}
/// The square control as `[x, y, w, h]` in normalized surface coordinates,
/// for a surface `aspect` (width / height) wide.
pub fn rect(self, aspect: f32) -> [f32; 4] {
let w = self.size / aspect.max(0.1);
[
self.center[0] - w / 2.0,
self.center[1] - self.size / 2.0,
w,
self.size,
]
}
/// Brings a value read from disk back on screen and into range.
pub fn sanitize(&mut self) {
self.size = finite_or(self.size, 0.15).clamp(0.045, 0.45);
self.opacity = finite_or(self.opacity, 0.65).clamp(0.0, 1.0);
for c in &mut self.center {
*c = finite_or(*c, 0.5).clamp(0.025, 0.975);
}
}
}
/// `button_visibility` values: follow the scheme, always shown, always hidden.
pub const FOLLOW_SCHEME: u8 = 0;
pub const SHOWN: u8 = 1;
pub const HIDDEN: u8 = 2;
/// The player's touch layout, saved as JSON. Missing fields take their
/// defaults, so later options can be added without resetting a saved file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub mode: Mode,
/// With [`Mode::Dpad`]: attack and use act on the crosshair.
pub split_controls: bool,
/// Mirrors every control left to right.
pub left_handed: bool,
/// With [`Mode::Dpad`]: the jump and sneak buttons trade places.
pub swap_jump_sneak: bool,
pub sprint_button: bool,
/// Sprint by pushing the joystick past its rim instead of with a button.
pub sprint_with_joystick: bool,
pub offhand_button: bool,
pub shield_button: bool,
/// Per control: [`FOLLOW_SCHEME`], [`SHOWN`] or [`HIDDEN`].
pub button_visibility: [u8; CONTROLS],
/// One row of placements per [`Mode`], indexed by [`Control`].
pub layouts: [[Placement; CONTROLS]; 3],
}
impl Default for Settings {
fn default() -> Self {
let row = [
Placement::new(0.13, 0.76, 0.32), // Move
Placement::new(0.72, 0.78, 0.105), // Sprint
Placement::new(0.92, 0.78, 0.16), // Jump
Placement::new(0.81, 0.86, 0.115), // Sneak
Placement::new(0.80, 0.64, 0.16), // Attack
Placement::new(0.92, 0.52, 0.16), // Use
Placement::new(0.46, 0.055, 0.075), // Chat
Placement::new(0.50, 0.055, 0.075), // Pause
Placement::new(0.54, 0.055, 0.075), // Settings
Placement::new(0.73, 0.95, 0.075), // Inventory
Placement::new(0.42, 0.055, 0.075), // Debug
Placement::new(0.58, 0.055, 0.075), // Perspective
Placement::new(0.80, 0.96, 0.075), // Offhand
Placement::new(0.92, 0.36, 0.12), // Shield
];
let mut layouts = [row; 3];
// The D-pad sits where the joystick does; sneak becomes its centre.
layouts[Mode::Dpad.index()][Control::Sneak as usize] = Placement::new(0.13, 0.76, 0.10);
layouts[Mode::Dpad.index()][Control::Jump as usize] = Placement::new(0.90, 0.78, 0.14);
Self {
mode: Mode::Crosshair,
split_controls: false,
left_handed: false,
swap_jump_sneak: false,
sprint_button: true,
sprint_with_joystick: false,
offhand_button: false,
shield_button: true,
button_visibility: [FOLLOW_SCHEME; CONTROLS],
layouts,
}
}
}
impl Settings {
/// Reads `path`; a missing or unreadable file gives the defaults.
pub fn load(path: &Path) -> Self {
let mut settings: Self = std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
settings.sanitize();
settings
}
/// Writes `path` through a temporary file, so a crash never leaves half a layout.
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let temp = path.with_extension("json.tmp");
std::fs::write(&temp, serde_json::to_vec_pretty(self)?)?;
std::fs::rename(temp, path)
}
pub fn sanitize(&mut self) {
// Only the D-pad has a split variant (see `Mode::SCHEMES`).
if self.mode != Mode::Dpad {
self.split_controls = false;
}
for v in &mut self.button_visibility {
if *v > HIDDEN {
*v = FOLLOW_SCHEME;
}
}
for p in self.layouts.iter_mut().flatten() {
p.sanitize();
}
}
/// Whether attack and use act on the crosshair rather than on a tap.
pub fn crosshair(&self) -> bool {
self.mode == Mode::Crosshair || self.split_controls
}
pub fn visible(&self, c: Control) -> bool {
match self.button_visibility[c as usize] {
SHOWN => return true,
HIDDEN => return false,
_ => {}
}
match c {
Control::Offhand => self.offhand_button,
Control::Shield => self.shield_button && !self.visible(Control::Use),
Control::Attack | Control::Use => self.crosshair(),
Control::Sprint => {
self.sprint_button && self.mode != Mode::Dpad && !self.sprint_with_joystick
}
_ => true,
}
}
/// The layout slot a control is drawn in (jump and sneak may trade places).
pub fn slot(&self, c: Control) -> usize {
let swap = self.mode == Mode::Dpad && self.swap_jump_sneak;
match c {
Control::Jump if swap => Control::Sneak as usize,
Control::Sneak if swap => Control::Jump as usize,
_ => c as usize,
}
}
/// Where a control is in the current scheme, mirrored for left hands.
pub fn placement(&self, c: Control) -> Placement {
let mut p = self.layouts[self.mode.index()][self.slot(c)];
if self.left_handed {
p.center[0] = 1.0 - p.center[0];
}
p
}
}
/// Whether `p` lies in the rectangle `[x, y, w, h]` (edges included).
pub fn contains([x, y, w, h]: [f32; 4], p: [f32; 2]) -> bool {
(x..=x + w).contains(&p[0]) && (y..=y + h).contains(&p[1])
}
fn finite_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() { value } else { fallback }
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_file(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("horizon-touch-{name}-{}.json", std::process::id()))
}
#[test]
fn mode_indices_match_layout_rows_and_schemes() {
for (i, mode) in Mode::ALL.into_iter().enumerate() {
assert_eq!(mode.index(), i);
}
for (mode, split, _) in Mode::SCHEMES {
assert!(!split || mode == Mode::Dpad);
}
for (i, c) in Control::ALL.into_iter().enumerate() {
assert_eq!(c as usize, i);
}
}
#[test]
fn scheme_decides_attack_use_sprint_and_shield() {
let mut s = Settings::default();
assert!(s.visible(Control::Attack) && s.visible(Control::Use));
assert!(s.visible(Control::Sprint));
// The shield button only stands in when Use is hidden.
assert!(!s.visible(Control::Shield));
s.mode = Mode::Tap;
assert!(!s.visible(Control::Attack) && !s.visible(Control::Use));
assert!(s.visible(Control::Shield));
s.mode = Mode::Dpad;
assert!(!s.visible(Control::Sprint));
s.split_controls = true;
assert!(s.crosshair() && s.visible(Control::Attack));
s.mode = Mode::Crosshair;
s.sprint_with_joystick = true;
assert!(!s.visible(Control::Sprint));
assert!(!s.visible(Control::Offhand));
}
#[test]
fn player_visibility_overrides_the_scheme_and_survives_a_save() {
let mut s = Settings {
mode: Mode::Tap,
..Settings::default()
};
s.button_visibility[Control::Attack as usize] = SHOWN;
s.button_visibility[Control::Chat as usize] = HIDDEN;
assert!(s.visible(Control::Attack));
assert!(!s.visible(Control::Chat));
let file = temp_file("visibility");
s.save(&file).unwrap();
let read = Settings::load(&file);
std::fs::remove_file(&file).unwrap();
assert_eq!(read, s);
}
#[test]
fn missing_bad_or_partial_files_fall_back_to_defaults() {
assert_eq!(Settings::load(&temp_file("absent")), Settings::default());
let file = temp_file("bad");
std::fs::write(&file, b"{ not json").unwrap();
assert_eq!(Settings::load(&file), Settings::default());
std::fs::write(&file, br#"{"mode":"dpad","left_handed":true}"#).unwrap();
let read = Settings::load(&file);
std::fs::remove_file(&file).unwrap();
assert_eq!(read.mode, Mode::Dpad);
assert!(read.left_handed);
assert_eq!(read.layouts, Settings::default().layouts);
}
#[test]
fn sanitize_brings_controls_back_on_screen() {
let mut s = Settings {
mode: Mode::Tap,
split_controls: true,
..Settings::default()
};
s.button_visibility[0] = 9;
s.layouts[0][0] = Placement {
center: [f32::NAN, 7.0],
size: 0.0,
opacity: f32::INFINITY,
};
s.sanitize();
assert!(!s.split_controls);
assert_eq!(s.button_visibility[0], FOLLOW_SCHEME);
let p = s.layouts[0][0];
assert_eq!(p.center, [0.5, 0.975]);
assert_eq!(p.size, 0.045);
assert_eq!(p.opacity, 0.65);
}
#[test]
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
let mut s = Settings::default();
let right = s.placement(Control::Jump).center;
s.left_handed = true;
assert_eq!(
s.placement(Control::Jump).center,
[1.0 - right[0], right[1]]
);
s.left_handed = false;
s.mode = Mode::Dpad;
s.swap_jump_sneak = true;
let row = s.layouts[Mode::Dpad.index()];
assert_eq!(s.placement(Control::Jump), row[Control::Sneak as usize]);
assert_eq!(s.placement(Control::Sneak), row[Control::Jump as usize]);
// Only the D-pad swaps.
s.mode = Mode::Crosshair;
assert_eq!(s.slot(Control::Jump), Control::Jump as usize);
}
#[test]
fn controls_stay_square_on_wide_surfaces() {
let p = Placement::new(0.5, 0.5, 0.2);
let [x, y, w, h] = p.rect(2.0);
assert_eq!([w, h], [0.1, 0.2]);
assert!(contains([x, y, w, h], [0.5, 0.5]));
assert!(contains([x, y, w, h], [x + w, y + h]));
assert!(!contains([x, y, w, h], [0.56, 0.5]));
}
#[test]
fn factory_controls_are_on_screen_in_every_mode() {
let s = Settings::default();
for row in s.layouts {
for p in row {
let mut clamped = p;
clamped.sanitize();
assert_eq!(clamped, p);
}
}
}
}
+83 -2
View File
@@ -217,6 +217,25 @@ pub fn blit_sprite(
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
}
}
/// `GuiGraphicsExtractor.blit(location, x, y, u, v, width, height, textureWidth,
/// textureHeight)`: a `src` region of a sprite — in its own texture's units, as the
/// u/v/width/height arguments are — stretched over `w` × `h`.
pub fn blit_sprite_region(
batch: &mut HudBatch,
atlas: &HudAtlas,
sprite: &str,
src: [f32; 4],
x: f32,
y: f32,
w: f32,
h: f32,
color: u32,
) {
if let Some(r) = atlas.sprites.get(sprite) {
let size = [r.w as f32, r.h as f32];
batch.rect_uv(x, y, w, h, sub(*r, size, src), color);
}
}
fn draw_sprite(
batch: &mut HudBatch,
r: Rect,
@@ -575,6 +594,7 @@ pub fn action_button(
label,
active,
hover,
false,
room,
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
);
@@ -602,10 +622,46 @@ pub fn button_as(
label,
active,
hover,
false,
0.0,
narration,
);
}
/// An options button whose focus highlights it without changing the mouse cursor.
/// `AbstractWidget.handleCursor` tests hover, not `isHoveredOrFocused`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn focused_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
active: bool,
hover: bool,
focused: bool,
action: Option<crate::widgets::Action>,
) {
let room = if let Some(action) = action {
crate::widgets::declare_action(action, rect, active);
if active {
crate::widgets::action_glyph_room()
} else {
0.0
}
} else {
0.0
};
button_with_room(
batch,
atlas,
rect,
label,
active,
hover,
focused,
room,
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
);
}
/// A button whose label keeps `room` free at its left.
#[allow(clippy::too_many_arguments)]
fn button_with_room(
@@ -615,6 +671,7 @@ fn button_with_room(
label: &Text,
active: bool,
hover: bool,
focused: bool,
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
@@ -624,11 +681,11 @@ fn button_with_room(
if active && plain.trim().is_empty() {
crate::widgets::target([x, y, w, h]);
}
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover || focused));
crate::widgets::widget_cursor(hover, active);
let sprite = if !active {
"gui/sprites/widget/button_disabled"
} else if hover {
} else if hover || focused {
"gui/sprites/widget/button_highlighted"
} else {
"gui/sprites/widget/button"
@@ -1258,6 +1315,29 @@ impl Caret {
rect: [f32; 4],
focused: bool,
focused_at: Instant,
) {
self.draw_color(
batch,
atlas,
(id, value, max),
rect,
focused,
focused_at,
rgba(224, 224, 224, 255),
);
}
/// [`Self::draw`] with the text in `color` (`EditBox.setTextColor`): the
/// settings tab turns an invalid Realm name red.
#[allow(clippy::too_many_arguments)]
pub fn draw_color(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(id, value, max): (crate::clipboard::FieldId, &str, usize),
rect: [f32; 4],
focused: bool,
focused_at: Instant,
color: u32,
) {
let mut idle;
let edit = if focused {
@@ -1270,6 +1350,7 @@ impl Caret {
};
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.focused = focused;
edit.text_color = color;
if edit.focused_at < focused_at {
edit.focused_at = focused_at;
}
@@ -423,5 +423,5 @@ fn vis_corner(position: vec3<u32>, rest: vec3<u32>, origin: vec4<f32>) -> Terrai
let texel = vis_sprite(t.uv, t.layer & 0x7fffu, du, dv);
var color = texel * t.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, t.visibility);
return solid_finish_at(t, texel, color, du, dv);
return solid_light(solid_finish_at(t, texel, color, du, dv));
}
+6 -1
View File
@@ -75,6 +75,10 @@ pub struct World {
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks loaded since the level's last pass, their entity sections not
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
/// the pass ends by readying them (`processPendingLoads`).
pub entities_pending: FxHashSet<(i32, i32)>,
/// Chunks that just came into this level from a worker, and whether each
/// was generated rather than read from the save: the game tells native mods.
pub loaded_chunks: Vec<(i32, i32, bool)>,
@@ -86,7 +90,7 @@ pub struct World {
/// interaction range.
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
pub block_names: FxHashMap<(i32, i32, i32), String>,
pub block_names: FxHashMap<(i32, i32, i32), horizon_world::nbt::Tag>,
/// States (by id) whose writes are recorded in `watched_writes` (sculk
/// blocks, which need a block entity).
pub watched: Vec<bool>,
@@ -214,6 +218,7 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
entities_pending: FxHashSet::default(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
attachments: Default::default(),
@@ -147,9 +147,10 @@ fn sunrise_sectors_match_reference_on_real_gpu() {
#[test]
fn native_pack_sky_keeps_sunrise_helpers_private() {
use wgsl_prefix::{TERRAIN, prefixed, section};
// Use the same complete terrain sections and prefixer as native_sky.rs.
// Use the same shared helper, complete terrain sections and prefixer as native_sky.rs.
let sky = prefixed(
&[
include_str!("../src/hdr_light.wgsl"),
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
@@ -179,7 +180,10 @@ fn native_pack_sky_keeps_sunrise_helpers_private() {
#[test]
fn complete_terrain_shader_still_validates_for_fallback_sky() {
let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap();
let module = naga::front::wgsl::parse_str(
&(include_str!("../src/hdr_light.wgsl").to_owned() + wgsl_prefix::TERRAIN),
)
.unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -459,5 +459,38 @@
"horizon.extras.horizon_me.tooltip": "Mich als Horizon-Spieler zeigen: andere Horizon-Spieler sehen das Logo neben deinem Namen. An, sagt der Dienst jedem, der nach dir fragt, dass du in den letzten 3 Stunden Horizon gespielt hast; aus, meldet er dich nie.",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)",
"horizon.studio.timeline.freecam_on": "Freie Kamera: EIN",
"horizon.studio.timeline.freecam_off": "Freie Kamera: AUS",
"horizon.studio.timeline.freecam_short": "Cam",
"horizon.studio.timeline.freecam_active_short": "Cam ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Bild",
"horizon.studio.timeline.delete_keyframe_short": "− Löschen",
"horizon.studio.timeline.split": "Teilen",
"horizon.studio.timeline.marker": "Markierung %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s Szenen",
"horizon.studio.timeline.scene_title": "Szene %s",
"horizon.studio.timeline.sequence": "Sequenz",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "Datei",
"horizon.studio.topbar.camera": "Kamera",
"horizon.studio.topbar.view": "Ansicht",
"horizon.studio.topbar.file_short": "Datei",
"horizon.studio.topbar.camera_short": "Cam",
"horizon.studio.topbar.view_short": "Ans.",
"horizon.studio.topbar.game_pause_menu": "⏸ Pausenmenü",
"horizon.studio.topbar.pause_short": "Pause",
"horizon.studio.topbar.open_recordings": "📁 Aufnahmeordner öffnen",
"horizon.studio.topbar.close_replay": "✕ Wiedergabe schließen",
"horizon.studio.topbar.exit": "Beenden",
"horizon.studio.topbar.toggle_freecam": "Freie Kamera umschalten",
"horizon.studio.topbar.reset_to_player": "👤 Auf Spieler zurücksetzen",
"horizon.studio.topbar.unlock_freecam": "🔓 Freie Kamera entsperren",
"horizon.studio.topbar.lock_to_spline": "🔒 An Pfad fixieren",
"horizon.studio.topbar.add_keyframe": "➕ Schlüsselbild hinzufügen",
"horizon.studio.topbar.add_marker": "Markierung hinzufügen",
"horizon.studio.topbar.clear_keyframes": "🗑 Schlüsselbilder löschen",
"horizon.studio.topbar.timeline_hud": "⏱ Zeitleiste",
"horizon.studio.topbar.split_scene": "Szene teilen"
}
@@ -63,6 +63,8 @@
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
"horizon.options.invert_y": "Invert Look Y",
"horizon.options.craft_on_servers": "Quick Craft on Servers",
"horizon.controller.direct_craft": "Quick Craft",
"horizon.options.sneak_button": "Sneak Button",
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
@@ -313,6 +315,7 @@
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Auto: tries it where your CPU limits the frame, keeps it if frames get faster.",
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
"No entity occlusion culling.": "No entity occlusion culling.",
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
@@ -575,6 +578,8 @@
"Reuses statue geometry until its block state changes.": "Reuses statue geometry until its block state changes.",
"Leaf Culling": "Leaf Culling",
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
"Opaque Ice": "Opaque Ice",
"Draws ice in the solid pass, without its see-through texture.": "Draws ice in the solid pass, without its see-through texture.",
"Distant Plant Dither": "Distant Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
"Screenshot Quality": "Screenshot Quality",
@@ -975,6 +980,7 @@
"A DualSense's light shows your health, from green through amber to red.": "A DualSense's light shows your health, from green through amber to red.",
"A DualSense's light changes with the dimension you're in.": "A DualSense's light changes with the dimension you're in.",
"A DualSense keeps its own light.": "A DualSense keeps its own light.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
"Show the buttons for what you can do, while you play with a controller.": "Show the buttons for what you can do, while you play with a controller.",
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
@@ -1006,5 +1012,38 @@
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
"horizon.recipe_transfer.unsupported": "Server requires a known recipe",
"horizon.studio.timeline.freecam_on": "Freecam: ON",
"horizon.studio.timeline.freecam_off": "Freecam: OFF",
"horizon.studio.timeline.freecam_short": "Cam",
"horizon.studio.timeline.freecam_active_short": "Cam ✓",
"horizon.studio.timeline.add_keyframe_short": "+ Key",
"horizon.studio.timeline.delete_keyframe_short": "− Del",
"horizon.studio.timeline.split": "Split",
"horizon.studio.timeline.marker": "Marker %s",
"horizon.studio.timeline.seconds": "%s s",
"horizon.studio.timeline.scene_count": "%s Scenes",
"horizon.studio.timeline.scene_title": "Scene %s",
"horizon.studio.timeline.sequence": "Sequence",
"horizon.studio.topbar.title": "✦ Studio",
"horizon.studio.topbar.file": "File",
"horizon.studio.topbar.camera": "Camera",
"horizon.studio.topbar.view": "View",
"horizon.studio.topbar.file_short": "File",
"horizon.studio.topbar.camera_short": "Cam",
"horizon.studio.topbar.view_short": "View",
"horizon.studio.topbar.game_pause_menu": "⏸ Pause Menu",
"horizon.studio.topbar.pause_short": "Pause",
"horizon.studio.topbar.open_recordings": "📁 Open Recordings Folder",
"horizon.studio.topbar.close_replay": "✕ Close Replay",
"horizon.studio.topbar.exit": "Exit",
"horizon.studio.topbar.toggle_freecam": "Toggle Freecam",
"horizon.studio.topbar.reset_to_player": "👤 Reset to Player",
"horizon.studio.topbar.unlock_freecam": "🔓 Unlock Freecam",
"horizon.studio.topbar.lock_to_spline": "🔒 Lock to Spline",
"horizon.studio.topbar.add_keyframe": "➕ Add Keyframe",
"horizon.studio.topbar.add_marker": "Add Marker",
"horizon.studio.topbar.clear_keyframes": "🗑 Clear Keyframes",
"horizon.studio.topbar.timeline_hud": "⏱ Timeline",
"horizon.studio.topbar.split_scene": "Split Scene"
}
@@ -196,10 +196,10 @@
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.profile": "Профиль",
"Particle Culling": "Particle Culling",
"Draws every particle.": "Draws every particle.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Skips offscreen, subpixel and terrain-hidden particles.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.",
"Particle Culling": "Отсечение частиц",
"Draws every particle.": "Рисует каждую частицу.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Пропускает частицы за экраном, субпиксельные и скрытые ландшафтом.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Пропускает частицы за экраном, субпиксельные и скрытые ландшафтом с меньшей нагрузкой на поток рендеринга.",
"General": "Общие",
"Advanced": "Дополнительно",
"Undo": "Отменить",
@@ -225,6 +225,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
opaque_ice: false,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
@@ -0,0 +1,139 @@
//! Environment attribute layer stacks for tools/test_environment_system_parity.py: per case, a
//! dimension's attributes, the biome layer and the weather layers, sampled at a position whose
//! biome mix comes from the Gaussian sampler over a test grid of biomes.
use horizon_world::environment_attributes::{ATTRIBUTES, AttributeMap, Kind, Value};
use horizon_world::environment_system::{BiomeBlend, Builder, Conditions, gaussian_sample};
use horizon_world::environment_timeline::Timeline;
use serde_json::Value as Json;
use std::{collections::HashMap, error::Error, fmt::Write};
fn bits(f: f32) -> String {
format!("{:08x}", f.to_bits())
}
fn format(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Float(f) => bits(*f),
Value::Rgb(v) => v.map(bits).join(" "),
Value::Argb(v) => v.map(bits).join(" "),
Value::Other(json) => json.to_string(),
}
}
/// The oracle's test grid: a cell's biome is picked by a hash of its coordinates.
fn cell(grid: &[String], x: i32, y: i32, z: i32) -> &str {
let hash = (x
.wrapping_mul(31)
.wrapping_add(y)
.wrapping_mul(31)
.wrapping_add(z))
& i32::MAX;
&grid[hash as usize % grid.len()]
}
fn run(
maps: &HashMap<String, AttributeMap>,
timelines: &[Timeline],
case: &Json,
output: &mut String,
) -> Result<(), Box<dyn Error>> {
let strings = |key: &str| -> Vec<String> {
case[key]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
};
let grid = strings("grid");
let position: Vec<f64> = case["position"]
.as_array()
.ok_or("position")?
.iter()
.filter_map(Json::as_f64)
.collect();
let map = |id: &str| maps.get(id).ok_or_else(|| format!("no map {id}"));
let mut builder = Builder::default();
builder.constant_layer(map(case["dimension"].as_str().ok_or("dimension")?)?);
let biomes = strings("biomes")
.iter()
.map(|id| map(id))
.collect::<Result<Vec<_>, _>>()?;
builder.biome_layer(biomes);
if case["timelines"].as_bool() == Some(true) {
for timeline in timelines {
builder.timeline_layer(timeline);
}
}
if case["weather"].as_bool() == Some(true) {
builder.weather_layers();
}
let system = builder.build();
let quart = |c: f64| (c / 4.0).floor() as i32;
let here = map(cell(
&grid,
quart(position[0]),
quart(position[1]),
quart(position[2]),
))?;
let mut blend = BiomeBlend::default();
let blended = case["blend"].as_bool() == Some(true);
if blended {
let scaled = [position[0] * 0.25, position[1] * 0.25, position[2] * 0.25];
gaussian_sample(
scaled,
|x, y, z| &maps[cell(&grid, x, y, z)],
|w, m| blend.accumulate(w, m),
);
}
let biome = |_full: bool| Some(here);
let conditions = Conditions {
rain: case["rain"].as_f64().ok_or("rain")? as f32,
thunder: case["thunder"].as_f64().ok_or("thunder")? as f32,
blend: blended.then_some(&blend),
biome: &biome,
clock: &|_| case["ticks"].as_i64().unwrap_or(0),
};
for attribute in ATTRIBUTES.iter().filter(|a| a.kind != Kind::Other) {
let value = system.value(attribute, &conditions).ok_or("no value")?;
let dimension = system
.dimension_value(attribute, &conditions)
.ok_or("no value")?;
writeln!(
output,
"minecraft:{}\t{}\t{}",
attribute.id,
format(&value),
format(&dimension)
)?;
}
Ok(())
}
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let mut lines = input.lines();
let data: Json = serde_json::from_str(lines.next().ok_or("no data")?)?;
let mut maps = HashMap::new();
let mut timelines = Vec::new();
for (id, json) in data.as_object().ok_or("data")? {
if id.starts_with("timeline:") {
timelines.push(Timeline::parse(json).map_err(|e| format!("{id}: {e}"))?);
continue;
}
maps.insert(
id.clone(),
AttributeMap::parse(json).map_err(|e| format!("{id}: {e}"))?,
);
}
let mut output = String::new();
for line in lines {
run(&maps, &timelines, &serde_json::from_str(line)?, &mut output)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,74 @@
//! Easings and timeline tracks for tools/test_environment_timeline_parity.py.
use horizon_world::environment_attributes::{Entry, Value, attribute};
use horizon_world::environment_timeline::{Easing, Track};
use std::{error::Error, fmt::Write};
fn bits(f: f32) -> String {
format!("{:08x}", f.to_bits())
}
fn format(value: &Value) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Float(f) => bits(*f),
Value::Rgb(v) => v.map(bits).join(" "),
Value::Argb(v) => v.map(bits).join(" "),
Value::Other(json) => json.to_string(),
}
}
fn ease(json: &str, x: f32) -> Result<String, Box<dyn Error>> {
Ok(match Easing::parse(&serde_json::from_str(json)?) {
Ok(easing) => bits(easing.apply(x)),
Err(_) => "error".into(),
})
}
fn track(
id: &str,
period: &str,
json: &str,
base: &str,
ticks: &str,
) -> Result<String, Box<dyn Error>> {
let attribute = attribute(id).ok_or("unknown attribute")?;
let base = match Entry::parse(attribute, &serde_json::from_str(base)?)? {
Entry::Override(value) => value,
_ => return Err("the base is not a plain value".into()),
};
let period = if period == "-" {
None
} else {
Some(period.parse()?)
};
let Ok(track) = Track::parse(attribute, &serde_json::from_str(json)?) else {
return Ok("error".into());
};
if let Some(p) = period
&& track.validate_period(p).is_err()
{
return Ok("error".into());
}
let sampler = track.sampler(period);
let values: Vec<String> = ticks
.split(',')
.map(|t| t.parse::<i64>().map(|t| format(&sampler.apply(&base, t))))
.collect::<Result<_, _>>()?;
Ok(values.join(";"))
}
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
let f: Vec<&str> = line.split('\t').collect();
let result = match f[0] {
"ease" => ease(f[1], f[2].parse()?)?,
"track" => track(f[1], f[2], f[3], f[4], f[5])?,
_ => return Err("unknown case".into()),
};
writeln!(output, "{result}")?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,93 @@
//! Dropped-item environmental damage, without a game or a server.
use horizon_client::{
assets::Assets, blocks::BlockFacts, chunk::Chunk, entities::ItemEntity, items::Items,
world::World,
};
use horizon_core::geometry::Vec3;
use horizon_world::loot::PackOverlay;
use std::{fs, path::Path, sync::Arc};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(args.get(1).ok_or("missing resources")?);
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let items = Items::load(root, assets.registry, &facts, &PackOverlay::NONE)?;
let mut world = World::new(facts.air, 1);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let mut output = String::new();
for line in fs::read_to_string(args.get(2).ok_or("missing inputs")?)?.lines() {
let f: Vec<_> = line.split('\t').collect();
if f.len() != 6 {
return Err("expected six tab-separated columns".into());
}
let position = Vec3::new(f[2].parse()?, f[3].parse()?, f[4].parse()?);
let block = if f[5] == "empty" {
"minecraft:cauldron"
} else {
"minecraft:lava_cauldron"
};
let secondary = match f[5] {
"pair" => "minecraft:lava_cauldron",
"mixed" => "minecraft:lava",
_ => "minecraft:air",
};
world.set(
1,
0,
0,
assets
.registry
.default_state(secondary)
.ok_or("missing secondary block")?,
)?;
world.set(
0,
0,
0,
assets
.registry
.default_state(block)
.ok_or("missing cauldron")?,
)?;
let mut item = ItemEntity {
rotation: [0.0; 2],
join: Default::default(),
health: 5,
fire_ticks: 0,
id: 1,
stack: horizon_client::item_data::parse_stack(f[1])?.ok_or("empty fixture stack")?,
thrower: None,
target: None,
position,
previous: position,
velocity: Vec3::ZERO,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
no_gravity: true,
vertical_collision: false,
horizontal_collision: false,
};
output.push_str(f[0]);
for _ in 0..2 {
if item.health > 0 {
item.environment_tick(&world, &facts, assets.registry, &items);
}
output.push_str(&format!(
"\t{},{},{}",
item.health,
item.fire_ticks,
item.health <= 0
));
}
output.push('\n');
}
fs::write(args.get(3).ok_or("missing output")?, output)?;
Ok(())
}
@@ -48,6 +48,8 @@ mod entity_particles_probe;
mod entity_sound_packet_fixture;
mod environment_attribute_modifier_fixture;
mod environment_fixture;
mod environment_system_fixture;
mod environment_timeline_fixture;
mod falling_block_fixture;
mod finalize_spawn_fixture;
mod fire_starter_sound_probe;
@@ -66,6 +68,7 @@ mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod late_entity_pair_fixture;
mod lava_cauldron_item_fixture;
mod light_parity;
mod lore_tooltip_fixture;
mod marker_particles_probe;
@@ -144,6 +147,7 @@ probes! {
stack_mining_probe,
cushion_placement_probe,
cauldron_fixture,
lava_cauldron_item_fixture,
chat_screen_fixture,
chest_pair_fixture,
chest_load_fixture,
@@ -163,6 +167,8 @@ probes! {
entity_particles_probe,
environment_attribute_modifier_fixture,
environment_fixture,
environment_system_fixture,
environment_timeline_fixture,
finalize_spawn_fixture,
fire_starter_sound_probe,
zombie_cure_sound_probe,
@@ -1443,7 +1443,7 @@ pub fn main() -> horizon_client::Result<()> {
// What a copper golem did to chests, before the next mob ticks.
for e in &events[from.min(events.len())..] {
match e {
MobEvent::ChestOpen { chest, open } => {
MobEvent::ChestOpen { chest, open, .. } => {
let o = world
.chest_openers
.entry((chest[0], chest[1], chest[2]))
Loaded 100 of 576 files, more files were not shown because too many files have changed in this diff. Show more