Compare commits

...
Author SHA1 Message Date
Ludovic Marie 6d586e2b23 Merge pull request #5800 from 1038april/feat/ram-limit-game
🤖 Agent: Review by Ludovic's AI agent (Claude). Opt-in RAM limit; off, it costs one atomic store per frame. kacpherek's grace-period point is fixed and tested. I merged main again (ram_limit next to random_textures, render_distance_in_use next to the leaf-culling remesh). Verify green on this head.
2026-11-01 17:11:02 +01:00
Ludovic Marie 7101e9fd45 Merge pull request #6033 from elythepreciouscat/feat/async-entity-culling
🤖 Agent: Asynchronous entity culling mode, off by default; the main merge now builds (EntityCulling arm closed, one field in round_trips).
2026-11-01 17:10:58 +01:00
Ludovic Marie c9d474dd91 Merge pull request #6053 from HrafnaValkyrja/fix/lightning-hits-all
🤖 Agent: Lightning tests every entity's box and destroys boats, carts and orbs as vanilla; main merged in (lan_e2e).
2026-11-01 17:07:56 +01:00
Ludovic Marie 308ef65272 Merge pull request #6064 from xFedeT/fix/tnt-minecart-collision
🤖 Agent: A minecart's move collides with the entities AbstractMinecart.canCollideWith accepts, so a TNT minecart explodes on another cart; costs nothing without carts.
2026-11-01 17:03:24 +01:00
Ludovic Marie 5e59e2f4ff Merge pull request #5900 from Teiwazik/shader/surface
🤖 Agent: shader API stack after main's #6005 and #5926: fmt, clippy -D warnings, client-render + shader-api tests green on the stack's last head 31d53c8b4; offscreen pack proof earlier on the same features.
2026-11-01 17:01:06 +01:00
ElyTheCatandOpenAI Codex 5472e113b2 Fix graphics culling merge errors
Remove the stale boolean assignment from the graphics round-trip fixture and close the Entity Culling UI match arm.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 19:28:46 +03:30
Ludovic Marie f122a023af Merge pull request #6065 from horizon-rust/perf/world-prepare-overlap
🤖 Agent: measured on playhorizon (alternated, THP off, 2 series): Done −144 / −157 ms median, 11/11 starts faster; idle +1.2-2.5 MiB; 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (1691 horizon-client tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:58:11 +01:00
Ludovic Marie df47724728 Merge pull request #5801 from 1038april/feat/ram-limit-launcher
🤖 Agent: Review by Ludovic's AI agent (Claude). Maximum memory row; default changed to no limit so big worlds and modpacks are never capped unasked. Launcher verify green on this head.
2026-11-01 16:57:39 +01:00
Ludovic Marie 1660c823e8 Merge pull request #5771 from masa-masa-0314/fix/shader-options-i18n
🤖 Agent: Review by Ludovic's AI agent (Claude). Translations of six existing Horizon strings in six languages; keys match en_us. I merged main; verify green.
2026-11-01 16:57:02 +01:00
Ludovic Marie 88ab49d369 Merge pull request #5717 from hasanbkaylan/patch-1
🤖 Agent: Review by Ludovic's AI agent (Claude). Turkish launcher strings only; checked the six fixes (typos, a stray Georgian letter). Verify green.
2026-11-01 16:56:53 +01:00
Ludovic Marie 1f629b560d Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-01 16:55:26 +01:00
Ludovic MarieandClaude Opus 5.5 fee0fee89f Merge main into #6053 (lan_e2e: lightning-box beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:54:42 +01:00
Ludovic Marie d1e32b4cae Merge pull request #6049 from Xmilon/container-open-hook
🤖 Agent: Mod container viewer counts; new head: a spectator is not a viewer (BarrelBlockEntity.startOpen).
2026-11-01 16:54:08 +01:00
Ludovic Marie ceec693555 Merge pull request #6019 from Xmilon/native/screen-mouse-dragged
🤖 Agent: Screen mouse drag pre event; new head: any button release ends the drag as MouseHandler.onButton clears activeButton, main merged.
2026-11-01 16:54:02 +01:00
Ludovic Marie e42d07a271 Merge pull request #6060 from pikammmmm/fix/lazy-chunk-datafix
🤖 Agent: Older chunk, entity and POI records of a current world upgrade as they are read (SimpleRegionStorage.upgradeChunkTag), reusing the ported datafixes; old_chunk_bits still reads the raw record.
2026-11-01 16:53:58 +01:00
Ludovic Marie 16a40a720a Merge pull request #5918 from loicpiller/fix/5867-piglin-bartering-offhand
🤖 Agent: Baby piglin equipment and gold pickups as vanilla; main merged in again (lan_e2e: piglin_offspring beside blast-projectiles).
2026-11-01 16:53:52 +01:00
Ludovic Marie b6f5c163de Merge pull request #6037 from horizon-rust/fix/ac-sprint-restart
🤖 Agent: reviewed: the record takes the client's own START/STOP (vanilla: causeExtraKnockback stops the server's sprint, LocalPlayer.aiStep restarts it silently with the key held); a sprint kept without the key or a +0.05 speed still counts (tests); Horizon's toggle sprint reports the key as held like vanilla's ToggleKeyMapping. e2e: honest cow-hit sprint never flagged (8 flags before), cheat bot 13; pr_ready --quick green (1713 tests).
2026-11-01 16:53:46 +01:00
Ludovic Marie 1a575997d6 Merge pull request #5952 from xFedeT/fix/dedicated-crash-save
🤖 Agent: A panic in the dedicated server's tick (console, control socket and players' turns included) still saves the world; crash fixture gated so the tick pays one OnceLock load (fix pushed).
2026-11-01 16:53:41 +01:00
Ludovic Marie b42d248a0a Merge pull request #6029 from oskal675-design/fix/submerged-boat-ejection
🤖 Agent: Submerged boats eject their player after 60 underwater ticks even when the client drives the boat (AbstractBoat.tick on the server), the request kept until every rider left; harness against a vanilla server; main merged in (lan_e2e).
2026-11-01 16:53:35 +01:00
Ludovic Marie fba4b16003 Merge pull request #6043 from xFedeT/feat/social-interactions
🤖 Agent: Social Interactions screen (P, pause icon): hide a player's chat locally as Minecraft.isBlocked in ChatListener.showMessageToPlayer, vanilla layout and sorting; no Mojang services touched.
2026-11-01 16:53:30 +01:00
Ludovic Marie 8863f5a229 Merge pull request #5779 from rdbtCVS/perf/encoder-finish
🤖 Agent: Review by Ludovic's AI agent (Claude). Measured on macOS (M4 Pro, Metal, same binary A/B): with the thread on, 44–293 FPS with 20–180 ms p99 frames, against 296–549 FPS off. So I made it the default on Linux only, where it measured +32%, and opt-in elsewhere (HORIZON_SUBMIT_THREAD=1). Merged main (OIT conflict; the hand still draws after the composite). Verify green.
2026-11-01 16:53:25 +01:00
Ludovic Marie 5a11614d8e Merge pull request #6026 from oskal675-design/fix/inventory-slot-hover
🤖 Agent: Creative grid and hotbar hover use the padded slot target (AbstractContainerScreen.getHoveredSlot) for highlight and tooltip, as clicks already did; oracle-backed e2e.
2026-11-01 16:53:21 +01:00
Ludovic Marie 74a2fddd2f Merge pull request #5932 from oskal675-design/fix/armor-stand-frost-walker
🤖 Agent: Frost Walker's location effect runs for armor stands as vanilla's LivingEntity.tick; reviewed, verify green.
2026-11-01 16:53:16 +01:00
Federico TaglianiandClaude Sonnet 5.5 09bb7254ae A minecart runs into other carts, boats, mobs and players, so a TNT minecart sets off on them
Entity.collide takes the entities AbstractMinecart.canCollideWith (AbstractBoat.canVehicleCollide:
canBeCollidedWith or isPushable, but not a passenger of the same vehicle). Horizon's cart move
only saw blocks, so two carts never had a horizontal collision and a TNT minecart never exploded
on another one.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:51:45 +01:00
ElyTheCat 4a9fc00315 Merge branch 'main' into feat/async-entity-culling 2026-11-01 19:19:58 +03:30
Ludovic Marie 183b53f0eb Merge pull request #6057 from HrafnaValkyrja/fix/mob-fall-on-blocks
🤖 Agent: Mobs run each block's fallOn through the landing registry (hay, honey, slime, powder snow, beds, dripstone...), the farmland roll on the mob's random as before; powder snow's entityInside forgets the fall.
2026-11-01 16:46:28 +01:00
Ludovic Marie 66e12814bd Merge pull request #6051 from HrafnaValkyrja/fix/projectile-land-event
🤖 Agent: PROJECTILE_LAND for thrown tridents, potions and fishing hooks (Projectile.onHit), so sculk sensors hear them; lan_e2e projectile_land check.
2026-11-01 16:46:24 +01:00
Ludovic Marie 7ba3d7ade7 Merge pull request #6061 from horizon-rust/perf/lazy-advancement-criteria
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.1 MB against #6041 on every start; Done and 20-bot MSPT unchanged. pr_ready ✅ (2099 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:46:20 +01:00
Ludovic Marie 2801c8ac63 Merge pull request #6027 from Xmilon/native/mouse-scrolling
🤖 Agent: Two-axis gameplay scroll hook (fragment): fires on whole accumulated steps behind the existing gameplay gate; no-op without a mod.
2026-11-01 16:46:16 +01:00
Ludovic Marie 375fa93fe6 Merge pull request #6023 from Xmilon/native/input-key-events
🤖 Agent: Window keyboard input hook (fragment): the original winit KeyEvent after handling, real window events only; no-op without a mod.
2026-11-01 16:46:12 +01:00
Ludovic Marie 0796fa9963 Merge pull request #6058 from 4404c/docs/chat-validation-note
🤖 Agent: Docs only: microsoft-account.md now says other players' chat is validated (#5149); skip-release.
2026-11-01 16:46:06 +01:00
Ludovic Marie f27399371d Merge pull request #6031 from Sticky-Bug/feat/flower-pot-hand-animation
🤖 Agent: A CONSUME use (empty flower pot with nothing to plant) no longer swings the arm: InteractionResult.shouldSwing is SUCCESS only, in use_hand and the remote prediction; level test covers it.
2026-11-01 16:46:01 +01:00
Ludovic Marie b938147a8e Merge pull request #5749 from some-du6e/optifine/cit-match
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 212746390). Thanks @some-du6e.
2026-11-01 16:45:57 +01:00
Ludovic Marie 8220ea6c65 Merge pull request #5712 from some-du6e/optifine/cit-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on e3ab4bd42). Thanks @some-du6e.
2026-11-01 16:45:53 +01:00
Ludovic Marie 7e05e90750 Merge pull request #5746 from some-du6e/optifine/ctm-mesh
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 936795944). Thanks @some-du6e.
2026-11-01 16:45:49 +01:00
Ludovic Marie 2bcace83be Merge pull request #5718 from some-du6e/optifine/ctm-connect
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 43b76c4a7). Thanks @some-du6e.
2026-11-01 16:45:45 +01:00
Ludovic Marie dde3ae3ce9 Merge pull request #5949 from Sylviromi/recipe-sync
🤖 Agent: Recipe sync hooks: arismoko's three points handled by the author (empty load resets clients, last part within its cap, per-load docs); main merged in by the author.
2026-11-01 16:45:41 +01:00
Ludovic Marie c7b4af6824 Merge pull request #5937 from xFedeT/fix/happy-ghast
🤖 Agent: requeued: kacpherek's review asked about per-tick cost; answered in the thread (a handful of obstacles, AABB tests only).
2026-11-01 16:45:37 +01:00
Ludovic Marie db90618377 Merge pull request #5998 from xFedeT/chore/port-map-triggers
🤖 Agent: Port map only: 16 advancement-trigger rows point at the triggers that fire on main; skip-release.
2026-11-01 16:45:33 +01:00
Ludovic Marie cb51db13a2 Merge pull request #5913 from xFedeT/fix/frog-animations
🤖 Agent: Frog eating a magma cube: drawn in and removed, and with the fix pushed here (mob_died runs on RemoveKilled) the froglight and XP drop as vanilla.
2026-11-01 16:45:27 +01:00
[email protected]andClaude Opus 5.5 1ffa50bf87 Merge upstream/main and end a drag on any button release
Review on #6019: vanilla's MouseHandler.onButton clears activeButton on any
release, so left down, right down, left up sends no drag while right is still
held. track_drag_button now does the same and the test pins vanilla's
behaviour.

The merge keeps both blocks in game.rs: the drag button tracking and the
screen key release hook (#6014).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:45 +01:00
[email protected]andClaude Sonnet 5.5 4dccd4cf01 fix(mods): a spectator does not count as a container viewer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:41:36 +01:00
Ludovic MarieandClaude Opus 5.5 9beedd8463 Merge main into the piglin branch (lan_e2e: piglin_offspring beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:41:22 +01:00
Ludovic MarieandClaude Opus 5.5 010239ced7 Crash fixture: scan the guests only when HORIZON_CRASH_FIXTURE names the point
as_guest runs for every guest action; the fixture's guest scan now waits
behind the OnceLock check, so a normal tick pays one load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:39:06 +01:00
Ludovic Marie 690f4ca210 Merge pull request #5711 from some-du6e/optifine/ctm-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 8881fc76f). Thanks @some-du6e.
2026-11-01 16:37:35 +01:00
Ludovic Marie 48798e24fb Merge pull request #5941 from HrafnaValkyrja/fix/explosions-push-projectiles
🤖 Agent: Explosions push projectiles, now thrown tridents too (fix pushed for kacpherek's review).
2026-11-01 16:37:30 +01:00
Ludovic Marie 2220e41e14 Merge pull request #5744 from some-du6e/optifine/etf-game
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on a4bda5e9e). Thanks @some-du6e.
2026-11-01 16:37:25 +01:00
Ludovic Marie 6009612677 Merge pull request #5926 from elythepreciouscat/feat/leaf-culling
🤖 Agent: Leaf culling: the default stays vanilla's, server packs now turn it off like local packs (author's cf2c59e1b); main merged in (leafCulling beside translucentBatching).
2026-11-01 16:37:20 +01:00
Ludovic Marie a5093dae37 Merge pull request #5716 from some-du6e/optifine/etf-pack
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 19996b09c). Thanks @some-du6e.
2026-11-01 16:37:14 +01:00
Ludovic Marie aa33a8303c Merge pull request #5898 from Vaspyyy/feat/native-music
🤖 Agent: native_music hooks: main merged in, camera_distance kept and shader_params keeps its own doc.
2026-11-01 16:37:08 +01:00
Ludovic Marie f3548dd3b3 Merge pull request #5892 from Domanffe/feat/chat-animation
🤖 Agent: Smooth chat (off by default): main merged in, extras.rs keeps hideBlockParticles beside chatAnimation/chatIndicator.
2026-11-01 16:37:01 +01:00
Ludovic Marie dedc3bbcfb Merge pull request #5710 from some-du6e/optifine/etf-rules
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on f2b47b87b). Thanks @some-du6e.
2026-11-01 16:36:56 +01:00
Ludovic Marie 4c9e871c0f Merge pull request #5665 from some-du6e/optifine/emf-animations
🤖 Agent: OptiFine series step verified (licence gate, gated to packs shipping the files, per-mob cost measured, verify green on 84bf62028). Thanks @some-du6e.
2026-11-01 16:36:50 +01:00
Ludovic Marie 3a5046218a Merge pull request #6014 from Xmilon/native/screen-key-released
🤖 Agent: Screen key release pre hook: the new head only isolates its test from the adjacent input hooks; no-op without a mod.
2026-11-01 16:36:42 +01:00
Ludovic Marie 653af80c4a Merge pull request #6003 from Xmilon/native/render-fog
🤖 Agent: native_render_fog hook: main merged by the author, frame_fog runs native_compute_fog_color then native_render_fog (NeoForge's ComputeFogColor then RenderFog); no-op without a mod.
2026-11-01 16:36:34 +01:00
Ludovic Marie e7f49e3695 Merge pull request #5925 from finalpantasy/fix/launcher-native-mod-refresh
🤖 Agent: requeued: kacpherek's review found no issue.
2026-11-01 16:36:27 +01:00
Ludovic Marie f49c54ba7f Merge pull request #5942 from oskal675-design/fix/spawn-eggs-on-source-fluids
🤖 Agent: requeued: parity failed only lunge_motion, a known flaky harness on the loaded Mac mini runner that this PR doesn't touch; rerunning.
2026-11-01 16:36:21 +01:00
Ludovic Marie 23fb690cff Merge pull request #5756 from finalpantasy/fix/native-sibling-lock-dependencies
🤖 Agent: Review by Ludovic's AI agent (Claude). kacpherek's review found nothing to change. The sibling-path check accepts only `../<id>` manifests of the same staged mod, and I added the underscore dependency tables, with a test. Launcher verify green.
2026-11-01 16:36:15 +01:00
Ludovic MarieandClaude Opus 5.5 bf890dc48e Merge main into lightning boxes (lan_e2e: lightning-box beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:36:11 +01:00
Ludovic Marie 47547980e4 Merge pull request #5731 from Stink-O/native/claimed-items
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's review and his off-hand fix are in; uses an API fragment as docs/native-mods.md asks. I merged main (clean); verify green on this head.
2026-11-01 16:36:09 +01:00
Federico TaglianiandClaude Sonnet 5.5 8eff24080b Dedicated server: test that a panic in a player's turn or with the hub out saves the world and no player file
HORIZON_CRASH_FIXTURE=guest|hub panics the tick during a placed guest's turn or in a console
line while a player is in. tools/test_dedicated_crash_save.py joins a real client, crashes the
server, and checks the player's files are byte for byte what the last clean stop wrote and a
block placed before the panic is there on the next start. The console lines and the control
socket now run under the same catch_unwind as the tick: a panic there ended the process with
no save.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:34:52 +01:00
Ludovic MarieandClaude Opus 5.5 9e58983bb7 Merge main into submerged boats (lan_e2e: boat_submerged beside shulker_lift)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:33:24 +01:00
HrafnaValkyrjaandClaude Opus 5.5 2b6ceab8a8 Mobs follow each block's landing rule
Mobs took the whole fall on every block. They now use the block's own fallOn behaviour, and powder snow makes them forget the fall.

Fixes #6056

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:25:26 -05:00
NazaandClaude Opus 5.5 6c577dcf69 Docs: other players' chat is validated since #5149
The chat signing section still said their signatures weren't verified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:25:24 -03:00
ElyTheCat d66271131c Merge branch 'main' into feat/leaf-culling 2026-11-01 18:55:12 +03:30
Ludovic MarieandClaude Opus 5.5 d9ad8b2601 Merge main into native music (camera_distance hook kept; shader_params keeps its own doc)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:24:11 +01:00
Ludovic Marie 9610099341 Merge pull request #6041 from horizon-rust/perf/lazy-loot-tables
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous memory −7.2 MB on every start, Done and 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (410 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:24:04 +01:00
Ludovic MarieandClaude Opus 5.5 83927e425b Merge main into chat-animation (extras: hideBlockParticles beside the chat extras)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:23:13 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5b14688cb4 Lightning hits boats, minecarts and orbs, by box
The bolt tested entity feet and a short list of kinds. It now tests each entity's box, as LightningBolt.tick does, and destroys boats, minecarts and orbs.

Fixes #6052

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:22:02 -05:00
Ludovic MarieandClaude Opus 5.5 672fe27345 Merge main into #5800; a RAM cap turned off or changed gets its whole grace again
Resolves far_terrain_choice with main's server far terrain (#lod_net): the RAM
limit's far_off still wins. kacpherek's review: the watcher kept the Over timer
while the cap was off, so turning it back on while over could end the game at
once. Over now follows the cap and restarts its grace when it changes; tested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:44 +01:00
HrafnaValkyrjaandClaude Opus 5.5 131c146a59 Sculk sensors hear tridents, potions and fishing hooks land
Horizon raised projectile_land only for the creatures list. Tridents, thrown potions and hooks now raise it too, as Projectile.onHit does.

Fixes #6050

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:21:25 -05:00
Ludovic MarieandClaude Opus 5.5 6d09cbb10f Explosions push thrown tridents too
Player-thrown tridents live in Game::tridents, apart from
creatures.projectiles, so the blast skipped them (kacpherek's review).
Same push as the other arrows: from the eyes (0.13), not in the ground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:21:06 +01:00
ElyTheCatandCodex cf2c59e1bd Leaf culling: account for active server packs
Include server resource-pack overlays in leaf and entity culling safety checks, and restore the selected leaf mode when a server pack is removed.

Co-Authored-By: Codex <[email protected]>
2026-11-01 18:51:01 +03:30
[email protected]andClaude Sonnet 5.5 9e90c9c9b6 feat(mods): hear a mod container's viewers change
Fixes #6047

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:20:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 3d6b8c6d18 Dropped items look only at the vehicles near them
The obstacle list was scanned in full for every moving item. A grid of 8-block cells hands each
item the boxes around where it can reach this tick. 5000 drops against 1000 spread-out vehicles:
12.6 ms of box checks become 0.45 ms.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 743a051f37 Merge main into the piglin branch (corpus: piglin pickups beside experience orb stuck)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:18:38 +01:00
Ludovic Marie 5e9745363f Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
#	crates/horizon-client/src/game/mods.rs
2026-11-01 16:18:32 +01:00
SylviromiandClaude Opus 5.5 cd6a6e1c89 Merge main into recipe-sync (the recipe filters run inside Recipes::sources)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:17:45 +03:00
pikammmmm f4d50e3122 Saves: upgrade older chunk, entity and POI records as they are read
Vanilla upgrades every record it reads from a region file
(SimpleRegionStorage.upgradeChunkTag, from ChunkMap.upgradeChunkTag,
EntityStorage and SectionStorage.PackedChunk.parse), so a world 26.3 has
opened keeps the chunks nobody loaded since an older version in that
version's format. Horizon upgraded region files only when level.dat itself
was older and decoded those records raw: a 1.18 to 26.2 chunk, whose palette
names its states with `Name` (26.x reads `id`), loaded as an all-air column.

SaveDir's region reads (read_record, load_far, lod_surfaces, the End gateway
migration and the POI claims merge) now run the chunk, entity-chunk and POI
fixes on older records, from vanilla's defaults (-1, POI 1945) when a record
has no DataVersion, with the dimension from its folder and the generator type
from world_gen_settings.dat, found once per SaveDir. old_chunk_bits keeps
reading the record as saved, as IOWorker.isOldChunk does.

Chunks of before 1.18 come out of the fixes with `below_zero_retrogen`, which
the client's chunk loading does not handle yet: they are still generated
again, as before.
2026-11-01 16:14:42 +01:00
Federico TaglianiandClaude Sonnet 5.5 42c338167d Add the Social Interactions screen: hide players' chat
SocialInteractionsScreen (P, or the pause menu's player reporting icon): the All, Hidden and
Blocked tabs, a search box, a row per player with the hide/show chat button and vanilla's
disabled report button. Hiding a player drops their chat (Minecraft.isBlocked in
ChatListener) and says so in the chat line. Blocking and reporting are Mojang's services, so
Blocked stays empty and reporting stays disabled.

Part of #5954.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 16:13:06 +01:00
TeiwazikandClaude Opus 5.5 63250fd4e8 Merge main into the surface branch
gfx.rs: the split around the occlusion test keeps main's pass timing and its list of passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:11:50 +01:00
SylviromiandClaude Opus 5.5 7268233fce Native mods: a load that asks for no recipe types puts the clients back on their own; the type list counts against the last part's cap
After a load that sent recipes, a later one whose mods ask for none sends
one last part with no types, so a joined client drops the server's old
recipes and hears of it (native_recipes_received with no types). The
last part's type list now counts against the payload cap: when it
doesn't fit, the recipes go in an earlier part. The sync hook's docs say
it is asked per load, the same for every player, where NeoForge asks per
joining player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:11:11 +03:00
Ludovic Marie 6f54a627d6 Merge pull request #4947 from horizon-rust/server-asks/lod-client
🤖 Agent: main merged after #4945/#4946 landed: main's new wanted_tiles queue now skips tiles waiting out their retry, as this PR's loop did. verify green on a6d37d7; tools/server_lod_e2e.py passed on Linux at the previous head (source server, capped at 16; none when unset).
2026-11-01 16:11:02 +01:00
Ludovic Marie 97873acc8b Merge pull request #5723 from TobiasLogic/shaderpacks/pack-vignette
🤖 Agent: Review by Ludovic's AI agent (Claude). A pack's vignette=false hides the game's vignette; with no pack nothing changes. I merged main and reworded a comment that named another project. Verify green.
2026-11-01 16:10:56 +01:00
Ludovic Marie 5bdb8885e6 Merge pull request #5701 from TobiasLogic/shaderpacks/translation-fixes-2
🤖 Agent: Review by Ludovic's AI agent (Claude). GLSL translation fixes, each with a test (GLSL 4.60 §4.1.9, §4.2.2), packs-only code. I merged main in; verify green on this head.
2026-11-01 16:10:51 +01:00
Ludovic Marie 6f30860d03 Merge pull request #5693 from Joansitoh/fix-3172-pack-uniforms
🤖 Agent: main merged again (the two uniforms go after main's isRightHanded … hasCeiling, looked up by name); verify green on 67ebb02. Thanks @Joansitoh.
2026-11-01 16:04:52 +01:00
Ludovic Marie bcba31c931 Merge pull request #5513 from Solenopsisbot/accounts/player-dir
🤖 Agent: levzzz5154's five review points fixed on the branch (crash telemetry stays on --home's consent, servers ignore the flag, limits documented, splitscreen guests on the game folder's mods intended, tests for shared_dir_of and servers_file_of); main merged; verify green on 9d56f33. Without --player-dir nothing changes. Thanks @Solenopsisbot.
2026-11-01 16:04:47 +01:00
Ludovic Marie 6ff6183171 Merge pull request #5820 from Detimes/feat-player-respawn-hook
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's two points are fixed (the hook runs through as_guest, after placement), the docs conflict with main is resolved, and the API fragment carries neoforge/side. Verify green.
2026-11-01 16:04:43 +01:00
Ludovic Marie 995dc2cef0 Merge pull request #5997 from oskal675-design/fix/shulker-box-player-lift
🤖 Agent: Opening shulker lids push the local player on the server-thread client as ShulkerBoxBlockEntity.tick does on both sides; lan_e2e shulker_lift check; verify green.
2026-11-01 16:04:38 +01:00
[email protected]andClaude Opus 5.5 027d02a8b0 Merge upstream/main into native/input-key-events
Keep both: the window key hook and the merged raw mouse button hooks (#6010)
sit side by side in native_hooks.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 b65e532430 Anticheat prediction: the record's sprint is the client's own, not the server's player's
A sprinting hit stops the server player's sprint (Player.causeExtraKnockback); the
client's own attack on a mob doesn't, and once the server's flag reaches it the
client starts the sprint again with the sprint key held, without a START_SPRINTING
(wasSprinting never changed). The record took the server's flag, so the replay
walked while the client sprinted: the live 'ground/air water, keys forward sprint'
flags. The record now takes the client's sprint commands; a sprint the server
stopped is played on only if vanilla's client could start it again (the sprint key,
or forward pressed again).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:41 +01:00
[email protected]andClaude Opus 5.5 2b878588bd Merge upstream/main into native/render-fog
Keep both fog hooks: the merged computed-color hook (#6000) runs first,
then the whole-fog hook sees the result and can change distances too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:00:23 +01:00
Ludovic Marie 2127463906 Merge main into optifine/cit-match 2026-11-01 15:59:12 +01:00
Ludovic Marie e3ab4bd423 Merge main into optifine/cit-rules 2026-11-01 15:59:10 +01:00
Ludovic Marie 9367959444 Merge main into optifine/ctm-mesh 2026-11-01 15:59:09 +01:00
Ludovic Marie 43b76c4a74 Merge main into optifine/ctm-connect 2026-11-01 15:59:08 +01:00
Ludovic Marie 8881fc76f0 Merge main into optifine/ctm-rules 2026-11-01 15:59:07 +01:00
Ludovic Marie a4bda5e9e1 Merge main into optifine/etf-game 2026-11-01 15:59:06 +01:00
Ludovic Marie 19996b09c9 Merge main into optifine/etf-pack 2026-11-01 15:59:04 +01:00
Ludovic Marie f2b47b87b4 Merge main into optifine/etf-rules 2026-11-01 15:59:04 +01:00
Ludovic Marie 84bf62028b Merge main into optifine/emf-animations 2026-11-01 15:59:03 +01:00
Ludovic Marie 486c22db0d Merge pull request #5646 from JustKarkat/test/experience-orb-stuck-case
🤖 Agent: #5601 has merged, so this test-only follow-up leaves draft: main merged (corpus union, every function checked against its source), verify green on 938f032. Thanks @JustKarkat.
2026-11-01 15:57:10 +01:00
Loïc PILLERandCodex GPT-6.1 2fd0193da5 docs: add piglin offspring PR screenshot
Record the empty-handed baby produced by the guest spawn-egg regression, for the before/after comparison in the pull request.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Loïc PILLERandCodex GPT-6.1 724ea7929a fix(mobs): let baby piglins pick up and keep gold
Match PiglinAi.wantsToPickup and stopHoldingOffHandItem: respect the baby ignored-item tag, admire picked-up gold, and equip it or preserve the previous loved item instead of bartering. Babies have no preferred weapon tag.

Compare 31 observations with vanilla through the integrated-server command harness, including adult bartering, occupied baby hands and ignored leather. Extend the hand-rendering regression with a crossbow and a gold ingot. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:57 +01:00
Ludovic MarieandClaude Opus 5.5 fea41f8676 Submit thread: on by default on Linux only, where it was measured faster
On the Mac mini (M4 Pro, Metal, offscreen, render distance 12) the same
binary ran 549/537/450 FPS without the thread and 44/51/293 with it, with
p99 frames of 178/160/21 ms against 4 ms. HORIZON_SUBMIT_THREAD=1 still
turns it on anywhere, =0 off; Windows is not measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:55:37 +01:00
Loïc PILLERandCodex GPT-6.1 efe1b716c7 fix(mobs): spawn baby piglins without adult equipment
Follow SpawnEggItem.spawnOffspringFromSpawnEgg: construct the piglin and mark it as a baby without running adult spawn finalization. Keep items picked up later intact.

Add a server-thread LAN regression that uses a spawn egg from the guest and checks that both clients see the same empty-handed baby. Refs #5867.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:55:23 +01:00
ElyTheCatandOpenAI Codex f68f5def32 Entity culling: add asynchronous occlusion mode
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 18:24:59 +03:30
Ludovic MarieandClaude Opus 5.5 c12490337f Keep advancement criteria's conditions as JSON, read on first test
The ~1,300 recipe advancements' criteria held ~7,000 JSON objects as
serde_json Values (~4 MB of a server's idle heap), most never tested in a
game. Criterion.conditions is now a LazyJson: read into a Value the first
time a criterion is tested, then kept. The recipe and inventory indexes
read each criterion once without keeping it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:54:55 +01:00
Ludovic MarieandClaude Opus 5.5 a6d37d7f68 Merge main into #4947: failed tiles still wait out their retry in main's wanted_tiles queue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:53:58 +01:00
HrafnaValkyrjaandClaude Opus 5.5 b2263f7eca Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 09:53:52 -05:00
Ludovic Marie f03d1b80a0 Merge pull request #5894 from Teiwazik/shader/block-texture
🤖 Agent: shader API series stacked on one union (each head holds the earlier ones); pr_ready --quick green on the last head 47b5a77 (2574 tests), shader-api tests + clippy on each earlier head; offscreen in-game run with a pack using all four features, no validation error.
2026-11-01 15:52:34 +01:00
Ludovic Marie 0817971f73 Merge pull request #6005 from kacpherek/perf/opaque-terrain-pass
🤖 Agent: Opaque terrain pass without discard: two solid passes on Metal (Apple's tiled GPUs, measured faster on M1 Pro), one pass elsewhere as before (fix pushed here); the far-tile opaque marking is fixed everywhere.
2026-11-01 15:52:27 +01:00
Ludovic Marie f860ccf026 Merge pull request #6012 from Sylviromi/datapack-command-reload
🤖 Agent: /datapack enable and disable reload the server's data as ReloadCommand.reloadPacks does (reload_world_tables, as /reload); four lines.
2026-11-01 15:52:19 +01:00
Ludovic Marie 737a34b70c Merge pull request #6010 from Xmilon/native/mouse-button-events
🤖 Agent: Raw mouse button pre/post hooks (fragments): no-ops without a mod, synthetic clicks skipped; test runs the real dispatch order.
2026-11-01 15:52:15 +01:00
Ludovic Marie 795b56ee09 Merge pull request #6000 from Xmilon/native/compute-fog-color
🤖 Agent: native_compute_fog_color hook (fragment): no-op without a mod, finite 0..=1 RGB only; test runs the real dispatch.
2026-11-01 15:52:11 +01:00
Ludovic Marie f113b76de6 Merge pull request #6006 from xFedeT/chore/port-map-stale
🤖 Agent: Port map only: 27 missing rows point at the code that ports them; skip-release.
2026-11-01 15:52:04 +01:00
Ludovic Marie cb356bf994 Merge pull request #5993 from HrafnaValkyrja/fix/boat-underwater
🤖 Agent: A boat used under water lands at the eye and is refused as in vanilla's BoatItem.use (ClipContext.Fluid.ANY on a full fluid cell); unit test, verify green.
2026-11-01 15:51:50 +01:00
Ludovic Marie 6f1501d309 Merge pull request #4946 from horizon-rust/server-asks/lod-server
🤖 Agent: far terrain 2/3 (the server samples tiles, off by default). #4945 and main merged, verify green on 6ee16d8; rdbtCVS approved, kacpherek's points fixed in the code; the stack's e2e passes on Linux.
2026-11-01 15:51:43 +01:00
Ludovic Marie de2577f9ff Merge pull request #5888 from Joansitoh/fix/5698-hide-break-particles
🤖 Agent: Extras: hide block-break particles, off by default; merged main in (extras.rs beside uiSoundReserve and splitscreen); verify green.
2026-11-01 15:51:35 +01:00
Ludovic Marie c6a691be44 Merge pull request #4945 from horizon-rust/server-asks/lod-wire
🤖 Agent: far terrain 1/3 (wire format, off by default). Main merged, verify green on 1f6b07c; rdbtCVS approved; the stack's e2e passes (tools/server_lod_e2e.py on Linux: server-sampled LOD capped at the server's 16 chunks, none when unset).
2026-11-01 15:51:30 +01:00
Ludovic Marie 7b97de798d Merge pull request #5868 from oskal675-design/fix/gliding-explosion-impulse
🤖 Agent: requeued: the parity failure was the runners' (a dead .horizon-prepare.lock link on runner horizon at playhorizon and horizon-2 at the Mac mini, now removed); the run is rerunning on the same head.
2026-11-01 15:51:25 +01:00
Ludovic Marie 9513756c20 Merge pull request #5944 from HrafnaValkyrja/fix/wide-mobs-suffocate
🤖 Agent: Wide mobs (hoglins…) suffocate in walls with vanilla's isInWall box; reviewed, verify green.
2026-11-01 15:51:20 +01:00
Ludovic Marie f9ccf49446 Merge pull request #5883 from Joansitoh/fix/5567-tnt-passengers
🤖 Agent: Primed TNT keeps its Passengers through save and load as vanilla's Entity.saveWithoutId; reviewed, verify green.
2026-11-01 15:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 67ebb0257e Merge main into #5693: the two uniforms after main's isRightHanded … hasCeiling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:47:07 +01:00
Sticky-BugandAntigravity a254e5ea31 Do not play hand animation when block interaction returns Consume (#5989)
In vanilla Minecraft, right-clicking an empty flower pot with an invalid item or empty hand consumes the action (InteractionResult::Consume) but does not swing the arm. In Horizon, use_hand unconditionally called swing_arm() whenever the interaction consumed the action, which also triggered swing_packet to LAN guests.

Gate swing_arm on InteractionResult::should_swing(), which only swings on Success.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 20:10:50 +05:30
[email protected]andCodex 202a24c553 test(mods): isolate release dispatch from adjacent input hooks
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:40:48 +01:00
oskal675-designandCodex GPT-6 4647a2b257 Eject every player from boats submerged too long
Count consecutive underwater ticks on the server for client-driven boats and use the existing rider-ejection path. Keep the request visible to all passengers until they have left. Compare real vanilla and Horizon passenger outcomes and cover host and guest immersion on the integrated server.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 19:38:23 +05:00
Ludovic Marie c05ef84a56 Merge pull request #5598 from JulesB40/ccr-b42808eb-bs7uli
🤖 Agent: security-reviewed by Ludovic's AI agent (Claude): the secrets file is now encrypted at rest (ChaCha20-Poly1305, key from the computer's machine-id), mode 0600, atomic replace, no token in logs or errors, the owner gate unchanged. 58 account tests pass on Linux (playhorizon); verify and launcher green on 0de577b. Thanks @JulesB40.
2026-11-01 15:37:33 +01:00
Ludovic Marie 5b9ffaaec6 Merge pull request #5992 from Xmilon/native/detached-camera-distance
🤖 Agent: native_camera_distance hook: a no-op fragment hook until a mod fills it, finite 0..=1024 overrides only, collision clipping kept; unit tests cover it.
2026-11-01 15:37:29 +01:00
Ludovic Marie f4d7883389 Merge pull request #5854 from Sylviromi/screen-render-post-prompt
🤖 Agent: Review by Ludovic's AI agent (Claude). The hook skips the screen the server pack prompt hides, as hud_layers draws Playing then; arismoko's in-game check confirms. I merged main; verify green.
2026-11-01 15:37:24 +01:00
Ludovic Marie 3bcd9c3372 Merge pull request #5795 from Detimes/docs-5785-player-projectiles
🤖 Agent: Review by Ludovic's AI agent (Claude). Docs only: bow PvP between players works since 5763854b0 (lan_e2e arrows, #5785's dedicated-server check). I merged main; verify green.
2026-11-01 15:37:19 +01:00
Ludovic Marie bb1a6be3fe Merge pull request #5789 from kacpherek/fix/dragon-every-dimension
🤖 Agent: Review by Ludovic's AI agent (Claude). EnderDragonRenderer has no dimension check; dragon_geometry returns at once with no dragon, and the dragon is cleared on a dimension change. I merged main; verify green.
2026-11-01 15:37:12 +01:00
Ludovic Marie 9d15888d8e Merge pull request #5778 from abit-foggy/fix/lod-anisotropic-streaks
🤖 Agent: Review by Ludovic's AI agent (Claude). Far tiles sample an explicit mip, only on tile faces (UVs past a block); block faces keep sprite_grad. A few ALU ops per far-tile fragment. I merged main; verify green.
2026-11-01 15:37:07 +01:00
Ludovic Marie 97f84d9beb Merge pull request #5984 from NerfPhaselock/perf/translucent-arena
🤖 Agent: Translucent batching 1/3: off by default (only horizon-graphics.txt turns it on), so players see no change; with it off the frame adds one bool check (follow_translucent_layout) and a branch per draw run, uploads and compaction keep their old paths. Measured gains in the body are for the opt-in path.
2026-11-01 15:37:03 +01:00
Ludovic Marie d1a804f466 Merge pull request #5741 from Stink-O/native/mod-screens
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's four points are addressed (overlay gate, client-only guard, gamepad note, #5617 merged in); API fragment. I merged main (merges with #5731 cleanly); verify green.
2026-11-01 15:36:58 +01:00
Ludovic Marie 57d83bfa07 Merge pull request #5910 from Sylviromi/recipe-filters
🤖 Agent: Data-pack and content-mod recipe removal/replacement by filter, load time only; reviewed, verify green.
2026-11-01 15:36:52 +01:00
Ludovic Marie 6c0bf45fd8 Merge pull request #5945 from HrafnaValkyrja/fix/spellcaster-hand-particles
🤖 Agent: Evoker and illusioner spell hand particles as vanilla's SpellcasterIllager.tick; reviewed, verify green.
2026-11-01 15:36:46 +01:00
Ludovic Marie cdda320c95 Merge pull request #5914 from HrafnaValkyrja/fix/plate-trident-rocket-hook
🤖 Agent: Tridents, rockets and fishing hooks press plates and trip tripwires (entity-inside checks as vanilla); reviewed, verify green.
2026-11-01 15:36:40 +01:00
[email protected]andCodex 76a6eed29c feat(mods): two-axis gameplay mouse scrolling event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:36:38 +01:00
Ludovic Marie 0d4edd409c Merge pull request #5988 from dill-lk/main
🤖 Agent: parity.py treats a remote OSError as a failed fetch instead of crashing; Python only, tests pass, verify green.
2026-11-01 15:36:36 +01:00
[email protected]andCodex 906567c48d feat(mods): complete window keyboard input event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:32:11 +01:00
oskal675-designandCodex GPT-6.1 003985afa4 Keep Creative item tooltips continuous across slot gaps
Reuse the padded container slot target for Creative grid and hotbar highlights and tooltips. Compare 50 rendered tooltip samples against actual vanilla slot hit-testing, including quarter-pixel boundary and Survival controls.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 19:30:18 +05:00
Ludovic Marie 7791063dfe Merge optifine/cit-match 2026-11-01 15:29:50 +01:00
Ludovic Marie e44c119bc0 Merge the connected-textures clippy fix 2026-11-01 15:29:49 +01:00
Ludovic MarieandClaude Opus 5.5 49aa71cc5e Sprites::insert_file: clippy's needless borrow (decode_file takes a &Path now)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:29:47 +01:00
[email protected]andCodex 55d67aaa4d feat(mods): screen mouse drag pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:28:09 +01:00
Ludovic MarieandClaude Opus 5.5 887f6e9750 Opaque pass: two solid passes on Metal only
The split saves Apple's tiled GPUs their hidden surface removal; elsewhere
the second pass doubled the solid draw calls (one per section without
multi-draw) and the vertex work for no gain that was measured. Other
backends keep the single fs_solid pipeline, the flat one included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:30 +01:00
[email protected]andCodex b88289edd1 test(mods): exercise actual mouse mapping release gate
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:24:56 +01:00
Ludovic MarieandClaude Opus 5.5 28aa93145d Format the opaque pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:23:51 +01:00
Ludovic Marie 15cc1e4d8e Merge remote-tracking branch 'origin/main' into pk1/6005 2026-11-01 15:22:57 +01:00
Ludovic Marie e6eed3e11f Merge pull request #5982 from TiggerTakkyTTT/fix/server-management-difficulty
🤖 Agent: Management protocol serversettings/difficulty get/set as MinecraftServerSettingsServiceImpl, saved to server.properties and server.toml; the management harness covers it.
2026-11-01 15:22:25 +01:00
[email protected]andCodex f8350d2240 feat(mods): screen key release pre event
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:22:20 +01:00
Ludovic Marie c58aeb2c52 Merge pull request #5973 from oskal675-design/fix/boat-paddles-on-dismount
🤖 Agent: Boat paddle flags reset to false (AbstractBoat.DATA_ID_PADDLE_*) when a stroke stops or the rider dismounts; new harness boat_paddle_reset compares against a vanilla server.
2026-11-01 15:22:18 +01:00
Ludovic Marie e937f37995 Merge pull request #5972 from HrafnaValkyrja/fix/splash-hits-thrower
🤖 Agent: Thrown potions hit players with vanilla's pick margin and leftOwner rule (Projectile.canHitEntity / checkLeftOwner); costs nothing when no potion is in flight; unit test covers the thrower case.
2026-11-01 15:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 9d56f3371a --player-dir: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:59 +01:00
Ludovic Marie 2ac1a8e83b Merge pull request #5958 from doodlyyousuf/fix/5912-painting-place-swing
🤖 Agent: Placing a painting swings the hand as vanilla's HangingEntityItem.useOn (InteractionResult.SUCCESS) does; tests pass, parity runs on the head.
2026-11-01 15:21:56 +01:00
Ludovic MarieandClaude Opus 5.5 1dd14c4cd2 Merge main into recipe-sync (server_data_loaded hook kept beside the recipe sync hooks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:55 +01:00
Ludovic Marie 4b2196179a Merge pull request #5659 from some-du6e/optifine/emf-geometry
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on 64d0b1a4e). Thanks @some-du6e.
2026-11-01 15:21:51 +01:00
Ludovic Marie 34cc13febe Merge pull request #5647 from some-du6e/optifine/emf-expr
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on b88a1aad1). Thanks @some-du6e.
2026-11-01 15:21:45 +01:00
[email protected]andCodex 0190d098d6 feat(mods): raw mouse button pre and post events
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:17:44 +01:00
Ludovic MarieandClaude Opus 5.5 01ae0bd791 Merge main into #5800
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:04 +01:00
Ludovic Marie 71b1ed56fe Merge pull request #5622 from some-du6e/optifine/emf-jem
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on d454a1fcb). Thanks @some-du6e.
2026-11-01 15:15:47 +01:00
Ludovic Marie ffb93f3791 Merge pull request #5986 from gigamans442/fix/custom-uniform-terminator
🤖 Agent: Shader packs: a custom variable/uniform expression may end with one ';' (trimmed), extra statements and empty expressions still rejected; unit tests.
2026-11-01 15:15:42 +01:00
Ludovic Marie 8d724b6150 Merge pull request #5980 from gigamans442/fix/shader-image-option-macros
🤖 Agent: Shader packs: custom image dimensions expand pack option macros (default and chosen), the option table is still built once; literals unchanged, unknown names still rejected; unit tests.
2026-11-01 15:15:38 +01:00
Ludovic Marie a61d6c8a5f Merge pull request #5974 from gigamans442/fix/shader-option-declared-default
🤖 Agent: Shader options: a declared default missing from its authored range stays selectable (inserted first), empty ranges still create no option; unit tests.
2026-11-01 15:15:33 +01:00
Ludovic Marie 7785e9c909 Merge pull request #5968 from gigamans442/fix/compute-dispatch-splats
🤖 Agent: Shader packs: a one-literal vector constant (ivec3(1)) in workGroups/workGroupsRender fills every component (GLSL 4.60 §5.4.2); negative or nested expressions still rejected; regressions fail before the fix.
2026-11-01 15:15:29 +01:00
Ludovic Marie de41830167 Merge pull request #5966 from TobiasLogic/shaderpacks/dimension-uniforms
🤖 Agent: Shader packs: isRightHanded, is_on_ground and the dimension's bedrockLevel/heightLimit/logicalHeightLimit/hasSkylight/hasCeiling get real values (black-box probe of the uniform values, no source read); set per frame only while a pack is on.
2026-11-01 15:15:24 +01:00
Ludovic Marie 75b1798f1d Merge pull request #5964 from TobiasLogic/shaderpacks/fog-frag-coord
🤖 Agent: Shader packs: gl_FogFragCoord becomes a float varying (hz_FogFragCoord) like gl_FrontColor; translator unit test; GLSL 1.20 spec, no other project's code. horizon-shaders only, verify green.
2026-11-01 15:15:19 +01:00
Ludovic Marie 8b11051f3b Merge pull request #5895 from Vaspyyy/feat/native-mob-submit
🤖 Agent: Client hook native_mob_submit as an API fragment; I fixed arismoko's two review points (walk state from mobs::anim::state: no walk while riding/dead, babies x3; hook asked before the sulfur cube's block). Verify green on 62e9cf2ba; the hook is a no-op stub without mods.
2026-11-01 15:15:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 c80a4f57d5 Port map: 27 rows that said missing are in the code
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:15:10 +01:00
Ludovic MarieandClaude Opus 5.5 97615443fe Launcher memory row: no limit until the player sets one
A 2048 MB default would give every player who updates a RAM limit they
never chose. On macOS and Linux the game starts stepping the render
distance down near 1843 MB, and big worlds or modpacks can pass that and
reach the last-resort crash. The row now starts at no limit, the end of
the slider; a typed or slid value is kept as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:09 +01:00
[email protected]andCodex e0c64b5cc8 feat(mods): adjust final world fog parameters
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:13:38 +01:00
Ludovic MarieandClaude Opus 5.5 c486c3a7a6 Merge main into #5801
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:34 +01:00
Ludovic Marie 3d9197a87a Merge optifine/cit-match 2026-11-01 15:12:22 +01:00
Ludovic Marie 20815f9448 Merge the connected-textures BlockProperties fix 2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 edee85f5d0 Connected textures: block state properties are main's BlockProperties
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:21 +01:00
Ludovic MarieandClaude Opus 5.5 70acb20025 Merge main into #5779
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:38 +01:00
levzzz 24ef0d341d Merge pull request #5976 from Xmilon/native/viewport-fov
feat(mods): computed world field-of-view hook
2026-11-01 19:11:28 +05:00
Federico TaglianiandClaude Sonnet 5.5 48ab95a550 Port map: AmethystBlock's projectile chime is ported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:10:48 +01:00
[email protected]andCodex e0d42f6719 feat(mods): adjust computed world fog color
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:10:03 +01:00
Ludovic MarieandClaude Opus 5.5 652981076f Native crates: read dev_dependencies and build_dependencies too
Cargo still accepts the underscore spellings before edition 2024, so a
dependency there skipped the lock check (levzzz5154's note on #5756).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:51 +01:00
Ludovic MarieandClaude Opus 5.5 4c69eab447 Merge main into feat/native-music (beside native_biome_colors)
The shader params doc comment goes back on native_shader_params: #5897 put
native_biome_colors between that comment and its function.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:24 +01:00
Ludovic MarieandClaude Opus 5.5 18a272eb7d Merge main into #5756
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:09:15 +01:00
Ludovic Marie 4aa6854a88 Merge pull request #5742 from some-du6e/fix/lod-near-detail
🤖 Agent: Reviewed lod.rs (seam stand-in rule in select and evaluate, coarse backdrop in the wanted queue, load order only). fmt/clippy/lod tests green in verify on this head. Thanks @some-du6e.
2026-11-01 15:08:52 +01:00
Ludovic Marie 081172f935 Merge pull request #5902 from Sylviromi/data-reloaded
🤖 Agent: Requeued after merging main (conflict beside native_data_pack_files, both hooks kept, 3 files +24 vs main). Server hook native_server_data_loaded(reload) as an API fragment; empty stub, no cost.
2026-11-01 15:08:46 +01:00
Ludovic MarieandClaude Opus 5.5 dc105082c2 Merge main into fix/5698-hide-break-particles (beside uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:07:30 +01:00
Ludovic Marie 4b87c54096 Merge pull request #5653 from Joansitoh/fix-3333-mojang-republish
CI: daily check for a republished Mojang version JSON
2026-11-01 15:07:10 +01:00
Federico TaglianiandClaude Sonnet 5.5 c61f82e61e Port map: 16 advancement-trigger rows that fire already say port, not missing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:07:09 +01:00
Ludovic MarieandClaude Opus 5.5 a498176bd7 Merge main into #5741
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:06:49 +01:00
HrafnaValkyrjaandClaude Opus 5.5 dd3e33c0ec Boats can't be placed from underwater
A fluid hit always snapped to the top of the water cell, so a boat landed
above a submerged player. In a full water cell the hit now stays on the
ray, as vanilla's VoxelShape.clip has it, so the boat collides with the
player and nothing is placed.

Fixes #5967

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 09:05:19 -05:00
Ludovic MarieandClaude Opus 5.5 b618b02a68 Merge main into #5731
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:05:11 +01:00
Ludovic MarieandClaude Opus 5.5 4f6522cbeb Merge main into feat/chat-animation (chat extras beside splitscreen and uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:04:49 +01:00
Ludovic Marie 885e287a2e Merge pull request #5897 from Vaspyyy/feat/native-biome-colors
🤖 Agent: Client hook native_biome_colors + native_refresh_biome_colors as an API fragment: runs only when a mod asks for a refresh (rebuilds sections like a Biome Blend change), nothing per frame; Biomes::with_colors unit-tested.
2026-11-01 15:03:56 +01:00
Ludovic Marie 3995900d53 Merge pull request #5904 from xFedeT/fix/nightly-parity-regressions
🤖 Agent: Harness diagnostics only: a JVM oracle that dies without an exception reports its exit/signal and last output (chunk_generation, datafix); no change to what is compared.
2026-11-01 15:03:49 +01:00
Ludovic Marie 89e1f987ec Merge pull request #5933 from Joansitoh/fix/border-wall-section
🤖 Agent: World border wall invalidated when the camera changes SectionPos (LevelRenderer.repositionCamera); an i32 compare per frame; world_border_render passes; verify green.
2026-11-01 15:03:44 +01:00
Ludovic Marie b7b413178f Merge pull request #5921 from Joansitoh/fix/lead-rope-rot-old
🤖 Agent: One line: rope_rot_old (yRotO/xRotO) set at the start of the server-client tick, as the local tick does, so the held lead end stops easing toward yaw 0; verify green.
2026-11-01 15:03:38 +01:00
Ludovic Marie 7979de85a9 Merge pull request #5878 from Joansitoh/fix/5719-font-region-full
🤖 Agent: A full dynamic glyph region no longer caches the missing glyph forever: overflow is retried after begin_frame clears the region (one bool check per frame); font unit tests cover overflow and no-provider chars; verify green.
2026-11-01 15:03:30 +01:00
Ludovic Marie 2d681d7794 Merge pull request #5874 from Joansitoh/fix/5642-osk-pointer-mode
🤖 Agent: Pointer menu mode: the on-screen keyboard auto-opens over chat/signs and A on a text field opens it, as focus mode does; pure helpers unit-tested; pad UI only, verify green.
2026-11-01 15:03:18 +01:00
Ludovic Marie 2dba644e25 Merge pull request #5884 from Vaspyyy/fix/5850-advancement-toast-color
🤖 Agent: Toast label colour packed with hud::rgba (yellow 0xFFFF00 / pink 0xFF88FF as AdvancementToast), unit test on the packed bytes; client UI only, verify green.
2026-11-01 15:03:13 +01:00
Ludovic Marie 938f0323ac Merge main into #5646
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-01 15:03:10 +01:00
Ludovic Marie f6ad13d707 Merge pull request #5887 from kacpherek/perf/gpu-pass-timings
🤖 Agent: queued with the depth_pass fix (130acf3d3). Thanks @kacpherek.
2026-11-01 15:03:08 +01:00
Ludovic Marie c9e839b403 Merge pull request #5857 from Lostpold88/fix/bow-arrow-physics
🤖 Agent: queued with my fix (9ec5db50d, see above): the remote arrow's flight rotation as vanilla's `AbstractArrow.tick` has it, without the two hunks that went against 26.3. Thanks @Lostpold88.
2026-11-01 15:03:03 +01:00
Ludovic Marie 86f6b049f1 Merge pull request #5685 from Stink-O/sound-channels-for-the-listener
🤖 Agent: verify was green before main's splitscreen extra conflicted. Main is merged in (40704247c), and the extra stays off by default. Queued for merge, thanks @Stink-O.
2026-11-01 15:02:52 +01:00
Ludovic MarieandClaude Opus 5.5 39b41a8d85 Merge main into #5820; the respawn hook fires once the player is placed, as them
Resolves docs/native-mods.md with main's native_player_command and
native_perform_console_command entries. levzzz5154's review:
- native_player_respawned runs through as_guest, so the helpers that act on
  the player (native_player, native_give_items...) reach the respawned one;
- it fires at the end of guest_respawn, after the bed, anchor and world-spawn
  placement and the hardcore switch, as PlayerRespawnEvent ends
  PlayerList.respawn; the docs name the later-tick trips and the End exit;
- the fragment carries neoforge PlayerEvent.PlayerRespawnEvent, side server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:02:26 +01:00
oskal675-designandCodex GPT-6 12b31598e2 Apply shulker box lid pushes on the client
Keep the client collision snapshot in sync with the animated lid and apply its existing swept-volume push, including while a container menu is open. Cover lift, support and closing for both players in survival and creative on the integrated server.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 19:01:21 +05:00
Ludovic MarieandClaude Opus 5.5 693e8b0892 Merge main into data-reloaded (beside native_data_pack_files)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:00:23 +01:00
Ludovic MarieandClaude Opus 5.5 e80ee4d10a Shader packs: the vignette comment names the property, not another project
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 e4b8313747 Merge main into #5723
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:30 +01:00
[email protected]andCodex 8dc3e7fbc5 feat(mods): adjust detached camera distance before collision clipping
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:58:33 +01:00
Ludovic MarieandClaude Opus 5.5 0a0d056239 Merge main into recipe-sync (both hooks kept beside native_data_pack_files)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:47 +01:00
Ludovic MarieandClaude Opus 5.5 502ffb18e0 Merge main into fix/5698-hide-break-particles (hideBlockParticles beside main's splitscreen)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:14 +01:00
Ludovic MarieandClaude Opus 5.5 dbe150799f --player-dir: crash telemetry keeps the home's consent, servers ignore it; tests and documented limits
levzzz5154's review: only logs/ and crash-reports/ follow --player-dir (crash
telemetry stays on --home, whose consent it reads); a dedicated server never
takes it. The launch reference says what stays shared (command history, Play
with Friends' relay) and that horizon-ctl logs and the launcher read --home.
Tests: shared_dir_of, servers_file_of.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:54:20 +01:00
Ludovic MarieandClaude Opus 5.5 d139c570fa Merge main into #5701
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:53:45 +01:00
SylviromiandClaude Opus 5.5 0db0837641 /datapack enable and disable reload the server's data, as /reload does
Vanilla's DataPackCommand ends in ReloadCommand.reloadPacks, which
reloads the server's resources: recipes, tags, loot and advancements
with the functions. Here enable and disable rebuilt only the function
store, so a pack's recipes stayed (or stayed away) until the next
/reload. They now reload the content tables too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:53:30 +03:00
gigamans442andCodex GPT-6 4078411d22 Keep compute dispatch splats limited to scalar literals
Reject unsupported negative and nested scalar expressions rather than dropping their operators when expanding a vector. Add a regression for the overbroad expansion.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:53:05 -05:00
Ludovic Marie de25a4d0a6 Merge main into #5513 2026-11-01 14:52:41 +01:00
Kacpherek 701e3ca790 Draw far tile faces over opaque texels in the opaque pass
Far tiles marked only leaves as opaque, so every stone, sand or ice face
of the far terrain went through the cutout pass and its discard. They are
marked as the mesher marks section quads now: opaque leaves and faces
over opaque texels.
2026-11-01 14:52:32 +01:00
Ludovic Marie 5106f5300e Merge pull request #5700 from calclb/fix/4689-tnt-igniter
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:52:17 +01:00
Ludovic Marie ebf11e2430 Merge pull request #5697 from ClickingMaster/fix/trident-pickup-animation
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:51:46 +01:00
Ludovic Marie 7799e903f6 Merge pull request #5695 from ASOwnerYT/fix/enderman-boat
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:51:14 +01:00
Ludovic MarieandClaude Opus 5.5 130acf3d3d Native shadow map: the depth pass takes the pass timing (main split begin into depth_pass)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:50:32 +01:00
Ludovic Marie 373a59f4e9 Merge pull request #5694 from spidercraft/fsr-entity-motion
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:50:27 +01:00
[email protected]andCodex 5c8fbc0c3e feat(mods): expose computed world field of view
Co-Authored-By: Codex <[email protected]>
2026-11-01 14:49:50 +01:00
Ludovic MarieandClaude Opus 5.5 144ce26f37 Merge main into #5771
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:49:46 +01:00
Ludovic Marie 4789f92dd3 Merge pull request #5686 from Stink-O/faster-fill
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:49:10 +01:00
Ludovic Marie d6e5c5a974 Merge pull request #5667 from JustKarkat/fix/phantom-glass-panes
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:48:28 +01:00
gigamans442andCodex GPT-6 380c15e6d3 Accept one trailing custom-expression terminator
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:39 -05:00
gigamans442andCodex GPT-6 9d0e0a0be5 Expand shader options in custom image dimensions
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:38 -05:00
gigamans442andCodex GPT-6 3c7da2b819 Keep declared shader defaults in selectable values
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:47:38 -05:00
Ludovic Marie fe6d441ac7 Merge pull request #5650 from TiggerTakkyTTT/fix/dragon-death-animation
🤖 Agent: verify is green and the branch merges cleanly with main. It also built and passed the Python tests in a combined branch with ~40 other open PRs. Read through: it looks right. Queued for merge (parity runs before it lands). Thanks!
2026-11-01 14:47:35 +01:00
Ludovic MarieandClaude Opus 5.5 2ec0f2bdc1 Merge main into #5778
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:47:34 +01:00
HrafnaValkyrjaandClaude Opus 5.5 7fd48091ae Thrown potions hit players, and their thrower after leaving them
A splash, lingering or XP bottle stopped at blocks and mobs only, so it
fell through any player. The step now meets players too. The thrower and
their vehicle group are skipped until the potion has left them, as
Projectile.canHitEntity and checkLeftOwner do.

Fixes #5876

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:46:49 -05:00
Ludovic MarieandClaude Opus 5.5 40704247c7 Merge main into #5685: uiSoundReserve beside main's splitscreen extra
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:28 +01:00
Ludovic MarieandClaude Opus 5.5 2b50470645 Chat: vanilla's fade keeps whole ticks while Smooth chat is off
ChatComponent's 200-tick fade reads Gui.getGuiTicks, a whole number, so it
steps 20 times a second. Passing the frame fraction made every player's fade
smoother than vanilla's; it now applies only with the Smooth chat extra on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:19 +01:00
Ludovic MarieandClaude Opus 5.5 865f808792 Merge main into #5854
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:45:26 +01:00
oskal675-designandCodex GPT-6.1 326532f859 Reset boat paddle metadata when rowing stops
Send the default false values when paddle entries disappear from tracked metadata, including dismounting mid-stroke. Compare 18 transitions with actual vanilla servers.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 18:44:30 +05:00
gigamans442andCodex GPT-6 f73179c3f1 Fix scalar vector constants in shader compute dispatch
Expand a scalar vector constructor to its declared component count when reading compute dispatch metadata. Cover absolute and relative dispatch, explicit component lists and undeclared defaults.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 08:43:51 -05:00
Ludovic MarieandClaude Opus 5.5 598fcb7b86 Mojang republish check: hourly, not daily (rdbtCVS: a day of broken prepares is too long)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:39 +01:00
Ludovic MarieandClaude Opus 5.5 dd96a77905 Merge main into #5789
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:14 +01:00
Ludovic MarieandClaude Opus 5.5 62e9cf2ba9 native_mob_submit: vanilla's walk state, and asked before the sulfur cube's block
- NativeMobRender takes the walk cycle, head turn and age from mobs::anim::state,
  the state the vanilla models read: no walking while riding or dead, a baby's
  legs three times as far (LivingEntityRenderer.extractRenderState,
  WalkAnimationState.positionScale).
- The hook is asked before a sulfur cube's swallowed block is drawn, so a
  mob a mod draws carries none of the vanilla model.

Review points from arismoko's review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:43:02 +01:00
Ludovic MarieandClaude Opus 5.5 8c1478525e Merge main into #5795
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:42:01 +01:00
NerfPhaselockandClaude Opus 5.5 f0b0890d6a Renderer: a Horizon pack's [shadow] keeps translucent quads in place
A pack of Horizon's own with [shadow] draws from the shadow camera's lists
(d36550e82), which makes frames flat where the device can, and flat draws
read translucent quads in the section arena. translucent_split now counts
that pass with a shaders/ pack's shadow programs, and a renderer made with
such a pack picked starts without the split (picked_pack_draws_flat, which
reads the pack's manifest; it was shaders_pack_picked).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:41:50 -05:00
Ludovic MarieandClaude Opus 5.5 0c69153b31 Frog: a mob eaten by a frog still drops its loot
RemoveKilled marked the dead target removed before the next tick's deaths
ran, and those skip removed mobs: a magma cube eaten by a frog dropped no
froglight and no experience. LivingEntity.die runs at the killing bite in
vanilla, before eatEntity removes it, so the handler runs mob_died first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:39 +01:00
Ludovic MarieandClaude Opus 5.5 1382a8f1c6 horizon-client-render: horizon-optifine listed once
Random entity textures, connected textures and CIT each added the dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:18 +01:00
Ludovic Marie 81ae4a7d8f Merge pull request #5783 from horizon-rust/sweep-g/launcher-native-builds
🤖 Agent: reviewed by Ludovic's AI agent (Claude): rdbtCVS's #5595 finished (main merged, "Built on {base}" in all 10 launcher languages); remove_version refuses a running build; verify and launcher checks green on e918209, merges cleanly with main. Thanks @rdbtCVS.
2026-11-01 14:41:09 +01:00
Ludovic MarieandClaude Opus 5.5 0de577b509 Launcher secrets file: encrypted at rest (ChaCha20-Poly1305, a key of the computer's)
The file kept the Microsoft refresh token and the Minecraft access token in
plain JSON. It is now sealed with a key derived from /etc/machine-id
(/etc/hostid on FreeBSD), so no token is on disk in plain text and a copy
taken off the computer can't be read. A file that doesn't open is refused
without quoting it and never written over, as a broken one was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:02 +01:00
Ludovic Marie 50f30b133f Merge remote-tracking branch 'du6e/optifine/cit-match' into of-union 2026-11-01 14:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 7e30d60e9e Merge optifine/cit-rules: CIT beside CEM, random entities and CTM
paths: one valid() test shared by resolve, in_root (CTM) and CIT. THIRD_PARTY_NOTICES.md
credits CIT Resewn Continuation (MIT), whose conditions cit.rs follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 75835557ff Merge optifine/ctm-mesh: connected textures beside main's LabPBR maps
RenderAssets keeps both the PBR maps and the connected-texture table; Sprites::insert_file
passes the root to main's register (a resource path has no namespace, so no PBR maps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:51 +01:00
Ludovic Marie aeadf92f84 Merge main into #5598 2026-11-01 14:39:50 +01:00
Ludovic Marie d02dd55ae3 Merge pull request #5946 from hello23113/fix/windows-cpu-name
🤖 Agent: Windows F3/crash-report CPU name from the registry's ProcessorNameString with the old fallback; read once (OnceLock) so no per-frame cost; the launcher starts the game with CREATE_NO_WINDOW so reg.exe opens no console. Parser unit-tested.
2026-11-01 14:39:20 +01:00
Ludovic Marie 805d85cec0 Merge pull request #5877 from Joansitoh/fix/5673-mcp-protocol-version
🤖 Agent: MCP initialize now negotiates the protocol version (echo only a supported one), same 12-line helper in horizon-ctl, horizon-server and the launcher, with unit tests; verify and launcher CI green.
2026-11-01 14:39:16 +01:00
Ludovic Marie e9351841b5 Merge pull request #5880 from levzzz5154/docs/content-mods-refresh
🤖 Agent: Docs only (mods.md, modpacks.md, template comment, manifest doc comment); checked the facts against the content loader (sounds key names a vanilla block, menu/entity_type kinds, mismatch refusal). The failing 'check' job is horizon-mod-registry's lock on main, unrelated.
2026-11-01 14:39:12 +01:00
Ludovic Marie 5f38815063 Merge remote-tracking branch 'du6e/optifine/ctm-connect' into of-union 2026-11-01 14:39:05 +01:00
Ludovic Marie 9c12b9b065 Merge remote-tracking branch 'du6e/optifine/ctm-rules' into of-union
# Conflicts:
#	Cargo.lock
#	crates/horizon-optifine/Cargo.toml
#	crates/horizon-optifine/src/lib.rs
#	crates/horizon-optifine/src/paths.rs
2026-11-01 14:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 fc292b22d1 Merge optifine/etf-game: random textures beside the custom entity models
Both sides of MobData, the pack reload and render_visible kept: a .jem's texture is
picked first, then its random variant. horizon-optifine listed once in horizon-client's
dependencies, and render_visible holds the CEM read guard instead of cloning its Arc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:38:44 +01:00
Ludovic Marie b2b7de9edf Merge remote-tracking branch 'du6e/optifine/etf-pack' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie ad5e50cbe6 Merge remote-tracking branch 'du6e/optifine/etf-rules' into of-union 2026-11-01 14:38:01 +01:00
Ludovic Marie b1b722682a Merge main into the OptiFine series 2026-11-01 14:38:00 +01:00
TobiasLogicandClaude Opus 5.5 b80d1a0851 Shader packs: isRightHanded, is_on_ground and the dimension's uniforms
Packs read these Iris uniforms as zero: isRightHanded (the Main Hand option), is_on_ground, and
the dimension type's bedrockLevel, heightLimit, logicalHeightLimit, hasSkylight and hasCeiling
(from the level's shape; hasCeiling in the Nether). A pack reading bedrockLevel 0 and heightLimit
0 in the overworld puts its fog or its sky's horizon at the wrong height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:23 +07:00
TobiasLogicandClaude Opus 5.5 9a02a6a0ed Shader packs: gl_FogFragCoord travels from the vertex stage to the fragment stage
GLSL 1.20's gl_FogFragCoord is a built-in varying: a vertex stage writes the fog distance and the
fragment stage reads it. naga knows no such name, so every program using it was skipped with
"Unknown variable: gl_FogFragCoord" (26 in MakeUp UltraFast, 26 in E-LITE, 18 in VanillAA, 1 in
Potato). It is now hz_FogFragCoord, a float output of the vertex stage and input of the
fragment stage, like gl_FrontColor and gl_TexCoord[n].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:37:21 +07:00
DoodlyyousufandGrok 4.6 e0d512e890 Swing the hand when a painting is placed
Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 13:36:50 +00:00
Ludovic MarieandClaude Opus 5.5 24399301ba Far terrain docs: dedicated servers only for now (an open-to-LAN world has no distance setting yet)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:36:47 +01:00
Ludovic Marie a9e7b49f04 Merge #4946 (main merged) into #4947 2026-11-01 14:36:28 +01:00
Ludovic Marie 6ee16d87cb Merge #4945 (main merged) into #4946 2026-11-01 14:36:25 +01:00
Ludovic Marie 1f6b07c82f Merge main into #4945 2026-11-01 14:36:24 +01:00
Ludovic Marie e1ac85c149 Merge pull request #5675 from martipops/split/fresh-profiles-switch
🤖 Agent: verify is green and the branch merges cleanly with main. The saved switch stays off by default, so nothing changes for a player who doesn't enable it. Queued for merge, thanks @martipops.
2026-11-01 14:36:24 +01:00
Ludovic Marie 867ed9b2eb Merge pull request #5623 from Sylviromi/native-reload-files
🤖 Agent: verify is green, the branch merges cleanly with main, and levzzz5154's review found it ready (load order below the world's packs, both first open and /reload covered). The hook's API number comes from its fragment when it lands. Queued for merge, thanks @Sylviromi.
2026-11-01 14:36:19 +01:00
TiggerTakkyTTTandCodex c05bd1a78c Add live difficulty methods to server management
Read and change the dedicated server difficulty through vanilla management
requests, broadcasting the change and saving it for restart.

Part of #5147

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 13:35:11 +00:00
Ludovic MarieandClaude Opus 5.5 12f28cf6c3 Merge main into #5598: the secrets file tests beside main's session refresh tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:34:07 +01:00
NerfPhaselockandClaude Opus 5.5 27a5ccdbca Renderer: translucent terrain in an arena of its own (off by default)
Translucent quads (water, stained glass, ice) sat right after their
section's solid quads in the 256 MiB arena pages, so the back-to-front list
changed page every few sections and only short runs merged: 3,832 draw calls
a frame over the ocean at render distance 64. With the new
translucentBatching setting on, they get a vertex arena of their own whose
pages may grow to about 2 GiB (LARGE_PAGE_BYTES: DirectX 12 and OpenGL cap a
buffer at i32::MAX), and the sorted indices the same cap, so the list goes
out as a few multi-draws. Vertex data, sorted indices and draw order are
unchanged.

The split holds only while no picked shaders/ pack can draw terrain or its
shadow pass flat (translucent_split); a pack that changes that drops the
sections and far tiles, which the game meshes again. compact_arena moves
what a section holds in the section arena: its solid range when split,
skipping sections without solid quads. LOD tiles and their fade stand-ins
draw and release from the translucent arena too. Batching is offered with
indirect multi-draws, not OpenGL's, and not where vanilla frames draw flat
(translucent_batchable). Every build_renderer caller passes the setting,
splitscreen and resource reloads included.

Off by default. RTX 4090, 1080p, off -> on: +4 to +16 % FPS at render
distance 32, up to +43 % at 64 (ocean 284 -> 406 FPS, 3,832 -> 354 draw
calls); arena memory +1 to +15 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:33:20 -05:00
Ludovic MarieandClaude Opus 5.5 aaf25ac9a9 Merge main into #5693: the pack inputs keep main's mod params beside the two new uniforms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:28 +01:00
finalpantasyandCodex ff45f545ce Merge upstream main to include its existing CI lint correction
Preserve the Home Mods refresh fix while incorporating upstream's correction
to BlockProperties::from_shared. The pull request remains one launcher file
relative to the pinned main revision.

Co-authored-by: Codex <[email protected]>
2026-11-01 08:30:55 -05:00
Federico TaglianiandClaude Sonnet 5.5 b183f74257 Dedicated server: a panic in the tick still saves the world, as runServer's finally does
MinecraftServer.runServer catches what the tick throws, writes the crash report, then
stops the server. A panic on the dedicated tick thread unwound out of run_dedicated, so
nothing since the last autosave was written. The tick is now under catch_unwind; a
panic ends the loop and saves the levels and the rest of the world state.

What the panic interrupted is not trusted: the guest whose turn (as_guest) it was has
older data in its record than the game holds swapped in, so it keeps its last saved file;
a hub taken out for the network tick (self.lan.take()) is gone with the unwind, so no
player is saved then.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:27:39 +01:00
Ludovic MarieandClaude Opus 5.5 5c785a252c Format the pass timing calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:30 +01:00
Ludovic Marie 7ec69de302 Merge remote-tracking branch 'origin/main' into pk1/5887 2026-11-01 14:27:16 +01:00
Ludovic Marie 90f08d3fbb Merge #5900 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 9ec5db50d1 Arrows keep the shooter's horizontal motion on the ground, as vanilla
Projectile.shootFromRotation adds the shooter's known movement and drops
only its y on the ground, and sets no rotation after it: an arrow shot
while strafing drifts with the player in vanilla too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:05 +01:00
Ludovic Marie dae5b4bce9 Merge #5894 into the shader API union 2026-11-01 14:27:00 +01:00
Ludovic MarieandClaude Opus 5.5 19fcd0d8a9 Keep loot tables as their JSON, read on first roll
As serde_json Values, vanilla's ~1,450 loot tables (0.6 MB of JSON,
~10,450 objects, a B-tree node each) held ~7 MB of a server's idle heap.
LootTables keeps each table's text and reads it into a Value the first
time it is rolled; every table is still read whole when it loads (a bad
file fails the load as before) and the native hook still runs on it, in
id order, before anything rolls it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:26:39 +01:00
HrafnaValkyrjaandClaude Opus 5.5 394725e84e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:26:11 -05:00
Ludovic Marie 374b50d48f Merge pull request #5890 from Teiwazik/fix/mesh-pool-lights
🤖 Agent: verified at e241409 on the Mac mini: fmt, clippy -D warnings (horizon-client-render), mesh_pool tests pass; the new assert fails without the fix. Thanks @Teiwazik.
2026-11-01 14:24:50 +01:00
Ludovic MarieandClaude Opus 5.5 6530425ace Load loot and live features beside the world as it opens
WorldRequest::prepare loaded the loot data (~40 ms on 4 CPUs) before the
world and installed the live features (bone meal's and saplings', ~46 ms)
after it, while the generator's decoration (~205 ms) kept one core busy.
Neither needs the world: they now run on scoped threads beside it and are
joined before prepare returns, so the loot data is still loaded before any
chunk is read. With a single worker thread they run in order as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:22:52 +01:00
Kacpherek aa4622805d Draw opaque terrain through a shader that never discards
The solid terrain shader could discard every pixel (the fade dither and
the alpha cutout), and one discard anywhere in a pipeline turns off the
hidden surface removal of tiled GPUs (Apple's) for everything it draws.
The world pass now draws each solid list twice: first the quads over
opaque texels that are not fading, through fs_opaque, which has no
discard, then the rest through fs_solid. Each vertex shader decides its
pass before any other work; a quad of the other pass lies outside the
clip volume. Shader packs and native materials keep the one pipeline.

M1 Pro, 1280x720, same commit before and after: GPU 10.4 -> 9.6 ms in a
flight over new ground, 12.9 -> 11.0 ms in rain, 9.5 -> 8.1 ms with 300
mobs in view. Screenshots are the same.
2026-11-01 14:22:09 +01:00
JoansitohandClaude Sonnet 5.5 0ce75665e4 Keep the held lead end on the look from the start of the tick
On the server-thread client, Game::tick returns tick_remote() before the
rope_rot_old write, so the first-person lead end eased toward the initial
(0, 10) every tick. Write it in tick_remote_level, after the unlit-chunk
early return, as the local tick does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:16:53 +01:00
JoansitohandClaude Sonnet 5.5 10531b1d64 Rebuild the world-border wall when the camera changes section
The wall mesh only holds the stretch inside render distance at build time
and was rebuilt only when the border itself changed, so after a respawn or
a long walk it stayed on the old stretch. Invalidate it when the camera's
section (floor >> 4 on X, Y, Z) changes, as LevelRenderer.repositionCamera.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:16:52 +01:00
HrafnaValkyrjaandClaude Opus 5.5 ce65bf2fd8 Evokers and illusioners show hand particles while casting
Two coloured entity_effect particles per tick, as SpellcasterIllager.tick
draws them, for guests too. The illusioner spell ids were swapped; mirror
is 4 and blindness 5.

Fixes #5886

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:16:50 -05:00
HrafnaValkyrjaandClaude Opus 5.5 0bf5c156fe Hoglins and other wide mobs suffocate in walls
The in-wall check tested one block and skipped mobs 1.0 or wider. It now
follows Entity.isInWall: a box 0.8 of the width at the eye.

Fixes #5872

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:16:31 -05:00
Ludovic Marie b01a37b403 Merge pull request #5938 from horizon-rust/fix/ac-sprint-one-tick
🤖 Agent: reviewed (the replay takes the shared flags' sprint bit the server sends, as vanilla's client); rebased on main; pr_ready --quick green on 2a1dd55 (1711 tests); the new test reproduces the live 0.0294/0.0455 signature before the fix; a +0.05 speed lie still counts from its first tick.
2026-11-01 14:12:17 +01:00
hsafandOpenAI Codex 499093d3da Fix Windows CPU name in F3 debug screen
Read ProcessorNameString from the Windows registry instead of displaying the raw processor identifier.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:09:16 +02:00
HrafnaValkyrjaandClaude Opus 5.5 43ec4c8319 Explosions push projectiles
Arrows, tridents, snowballs, fire charges and rockets never felt a blast,
so TNT minecart arrow launchers did nothing. They now take vanilla's
push from their eye position, with each type's real eye height (0.13 for
arrows and tridents, 0 for wind charges). An arrow in the ground stays.

Fixes #5908

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:07:20 -05:00
SylviromiandClaude Opus 5.5 34ba8585ed Recipe book: a namespaced recipe keeps its id
The book's model and the native recipe list made every crafting and
cooking id `minecraft:<name>`, so a content mod's or a data pack's
`mymod:thing` became `minecraft:mymod:thing`. Only a bare vanilla name
takes the prefix now (crafting::recipe_id), and the native list reads
cooking times from the recipes in use, not the content root's alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:10 +03:00
Ludovic MarieandClaude Opus 5.5 2a1dd555d0 Anticheat prediction: the server's sprinting flag reaches the replay
A client whose STOP_SPRINTING and START_SPRINTING reach the server in two
server ticks gets the STOP's shared flags and movement speed (no sprint
modifier) after it sprints again: its flag cleared, LocalPlayer.aiStep
starts the sprint again, modifier included, and sends nothing. The
replay took the attributes but not the flag, so it sprinted on without
the modifier until the START's attributes came: 0.029 then 0.045 off,
the official server's "ground sprinting" flags (#5935).

Record the sprinting bit of the shared flags the server sends the player
(Change::Sprinting) and apply it to the replay's flag alone, as
SynchedEntityData.assignValues does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:04:46 +01:00
SylviromiandClaude Opus 5.5 45480f7b1b Native mods: forget the recipe book's model through its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:49 +03:00
SylviromiandClaude Opus 5.5 08fd8f8a1f Native mods: the synced recipes reach the recipe book's list, and the mods hear of them once the player is there
The window's recipe book model, which native_recipes lists the vanilla
kinds from, is made again from the server's recipes when they arrive.
The recipes of a join come before the window has its player, when the
helpers answer nothing, so native_recipes_received waits for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:01:14 +03:00
Federico TaglianiandClaude Sonnet 5.5 8773007a60 A dropped item rests on a boat, a minecart, a shulker or a held-still happy ghast
Entity.collide stops a moving entity at the entities whose canBeCollidedWith(it) is
true. An item only looked at blocks, so one dropped onto the back of a happy ghast
(held still while a player stands there) fell through it, as it did through a boat.
The server's item tick now gets the boxes of boats, minecarts, living shulkers and
happy ghasts on a still timeout.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:00:04 +01:00
Ludovic Marie 8c02c0b853 Merge pull request #5873 from horizon-rust/fix/prediction-item-use
🤖 Agent: rebased onto main (conflict with #5856's frozen test helper, both kept); pr_ready --quick green on 30dfdc3 (1689 horizon-client tests, 19 prediction tests on vanilla data).
2026-11-01 13:59:30 +01:00
Ludovic MarieandClaude Opus 5.5 30dfdc3e1c Anticheat prediction: a client still slowed by an item use the server ended isn't flagged
For 10 ticks after the server's item use ends, the replay also tries the
client still slowed by it: the client finishes a use on its own ticks and the
server on its own (food eaten one after another with the key held), and the
server stops one for its own reasons. Slower than the keys allow is no
advantage; a use the server has and the client may not still counts at once.

tools/anticheat_e2e.py: prediction-items (a shield, food and a bow used on
the move) and prediction-water (a deep pool, a current, a bubble column, a
ledge) with a real Horizon client.

Fixes #5860.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:50:39 +01:00
VaspyyyandClaude Opus 5.5 e855aea99b Native mods: native_mob_submit's frame after main's kept geometry
Main added `kept` to BlockEntityDraw (keep a block entity's geometry);
a mob's frame starts with it unset, and the hook says `keep` does nothing
for a mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 12:50:04 +00:00
SylviromiandClaude Opus 5.5 c79b897210 Native mods: send a server's recipes to its Horizon clients
A server's native mods name the recipe types their client halves need
(native_datapack_sync, NeoForge's OnDatapackSyncEvent.sendRecipes). The
server sends those types' final recipes, data packs merged and load
conditions held, to every Horizon client on a join and after each
/reload, on horizon:recipe_content in payloads within the clientbound
cap. The client's tables take them in place of its own for those types,
so native_recipes and native_recipes_of_type match the server, single
player included, and native_recipes_received (RecipesReceivedEvent) says
they came. With no type asked nothing is sent; vanilla clients are sent
nothing new.

Recipes::load is split into sources and from_sources so the server and
the client build from the same list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:49:11 +03:00
ElyTheCat 5c8a4156ff Merge remote-tracking branch 'origin/main' into feat/leaf-culling 2026-11-01 16:17:51 +03:30
Ludovic Marie 35f5e95f5d Merge pull request #5931 from horizon-rust/fix/props-needless-borrow
🤖 Agent: fixes main's verify (clippy needless_borrow from #5915). `cargo clippy --workspace --all-targets -- -D warnings` (clippy 0.1.99, CI's version) and `cargo fmt --check` clean on the Mac.
2026-11-01 13:47:01 +01:00
Ludovic Marie 82f8d0a0be Merge pull request #5930 from horizon-rust/fix/connection-write-timeout
🤖 Agent: proof: pr_ready --quick green (1680 horizon-client tests); write timeout = read timeout; local frozen-player run ends both threads; ships in the next batched restart for the live measurement.
2026-11-01 13:44:48 +01:00
Claude 7a767d30db Merge remote-tracking branch 'upstream/main' into feat/native-mob-submit 2026-11-01 12:43:51 +00:00
Ludovic MarieandClaude Opus 5.5 4a6ed638b3 Fix clippy's needless borrow in BlockProperties::from_shared
#5915's last revision made the shared pairs references, so the sort's
comparison borrowed a reference: main's verify (clippy -D warnings)
fails on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:43:50 +01:00
oskal675-designandCodex GPT-6.1 16a9935c6a Run armor stand location enchantments on equipment and movement
Apply the existing replace-disk evaluator for armor stand block changes,
landings and changed equipment slots. Keep piston support flags current
and verify the behavior against actual vanilla servers.

Remove a needless borrow in the upstream properties sort for Clippy.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 17:41:24 +05:00
ElyTheCatandCodex 2a9a6c1e66 Graphics: capture leaf culling modes
Co-Authored-By: Codex <[email protected]>
2026-11-01 16:08:55 +03:30
Ludovic Marie f8d5d60c0d Merge pull request #5923 from horizon-rust/fix/connection-cap-per-address
🤖 Agent: proof: pr_ready --quick green on 98e49e3 (1680 horizon-client tests); live on the official server since 12:22Z as dd4b209 (this head minus the test commit), status pings ok every minute, 99 joins in 15 min; the deadlock of the first deploy is reproduced by the test without the fix.
2026-11-01 13:38:28 +01:00
Ludovic MarieandClaude Opus 5.5 1dc2f505bb lan: a write waits at most 30 s; log places closing
A peer that stops reading (a frozen game, a dropped network) held its
writer in a full send buffer until TCP gave up, minutes later, and the
connection's place with it: play() joins the writer. Writes now time out
like reads. The turned-away report also says how many places are before
play and how many wait for a writer to finish.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:37:34 +01:00
Ludovic Marie b8b5020fbb Merge pull request #5679 from TiggerTakkyTTT/fix/end-portal-entities
🤖 Agent: main moved after the queue (#5875 and #5755 merged), so I merged main in again (clean, d635bc57f). `cargo check -p horizon-client --lib --bins --tests` passes. Requeued.
2026-11-01 13:36:36 +01:00
Ludovic Marie ff6cad6206 Merge pull request #5903 from repositorykreml1n/fix/attack-through-gaps-5772
🤖 Agent: proven on a union with main c1f4a7c19 (+ #5875, #5755, #5679): `pr_ready.py --quick` passed (fmt, clippy -D warnings, horizon-client tests, doc tests). I added a lan_e2e check, `attack_over_slab` (97c18d712): a NoAI zombie stands behind a bottom slab, the player aims at its head over the slab and left-clicks. It passes for the host (20 → 19.06) and for the guest (19 → 18). Queued for merge. Thanks @repositorykreml1n!
2026-11-01 13:35:28 +01:00
Ludovic Marie d635bc57f9 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 13:34:42 +01:00
finalpantasyandCodex f5014f4060 Fix Home Mods refresh after native files change during a read
Carry the fingerprint captured at read start into asynchronous completion. Keep an older result stale when a newer fingerprint was observed, allowing the next render to load the updated list.

Add a deterministic regression using owned temporary native-mod files and real discovery and fingerprints. It fails with the original completion behavior and verifies both files after the follow-up read.

Fixes #5907.

Co-authored-by: Codex <[email protected]>
2026-11-01 12:33:10 +00:00
Ludovic Marie 7bca933bb5 Merge pull request #5755 from navinor/fix/unbreakable-durability-bar
🤖 Agent: verified by Ludovic's AI agent (Claude). ItemStack.isBarVisible needs isDamageableItem, which an unbreakable stack isn't, and the unbreakable unit component now reaches clients. pr_ready --quick passed on main c1f4a7c19 with this PR, #5875, #5903 and #5679 merged in (fmt, clippy, horizon-client tests incl. the new sync test).
2026-11-01 13:30:37 +01:00
Ludovic Marie d0afa1edc1 Merge pull request #5875 from TiggerTakkyTTT/fix/creative-shulker-drops
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against ShulkerBoxBlock.playerWillDestroy and Block.popResource (offsets drawn before the block_drops check, as vanilla). pr_ready --quick passed on main c1f4a7c19 with this PR, #5903, #5755 and #5679 merged in (fmt, clippy -D warnings, horizon-client tests, Python tests). CI parity covers the new command_exec cases.
2026-11-01 13:30:33 +01:00
Ludovic Marie 5bbecdc497 Merge pull request #5915 from horizon-rust/perf/block-state-properties
🤖 Agent: playhorizon 4 CPUs: idle RSS −17 MB with THP off (241.7 → 224.7, every start) and −18 MB median with THP on (4/4), live heap −13.6 MB, Done 2.06 → 1.955 s (4/4); 20-bot MSPT unchanged (3 rounds). pr_ready tests 2066 + content roots pass; merge with main checks; fmt/clippy/Python clean on the Mac.
2026-11-01 13:23:50 +01:00
Ludovic MarieandClaude Opus 5.5 98e49e36c8 lan: make room in the test with a log writer set
The deadlock fixed in the previous commit only shows with a log writer;
the test hangs without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:22:51 +01:00
Ludovic MarieandClaude Opus 5.5 49e91c58c2 Merge #5875 into #5679: the harness's break and falling ops side by side
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:34 +01:00
Ludovic MarieandClaude Opus 5.5 675fba85db Merge main into #5679; a native-spawned falling block starts with no portal state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:11 +01:00
Ludovic MarieandClaude Opus 5.5 97c18d7127 lan_e2e attack_over_slab: a hit aimed at a zombie's head over a bottom slab lands (#5772)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:05 +01:00
Ludovic Marie b1d9448018 Merge remote-tracking branch 'origin/main' into g/5679 2026-11-01 13:21:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 b45e1c9dd3 Frog: the tongue sets its target's velocity and a killed target vanishes at once
ShootTongue.tick sets the target's delta movement (setDeltaMovement, not an add) toward
the frog, and eatEntity removes a target that died (remove(KILLED)): the magma cube is
drawn into the mouth and disappears, with no death animation next to the frog.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 13:15:49 +01:00
HrafnaValkyrjaandClaude Opus 5.5 055ce500a0 Tridents, rockets and fishing hooks press plates and trip tripwires
They live in their own lists, so a plate's entity count never saw them.
They now count, as vanilla's getEntitiesOfClass does. A rocket boosting a
player and a hook on an entity count but skip entityInside, whose step
their ticks skip too. A guest's hook ends when the guest changes level, so
it can't act on the blocks of the level it arrives in.

Fixes #5896

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 07:14:27 -05:00
ElyTheCatandAntigravity 81862bbc39 Merge origin/main into feat/leaf-culling; add benchmark screenshots
Resolve graphics.rs serialize conflict: insert leafCulling between
entityCulling and chunkUpdates, keeping main's textureBudgetMiB and
ditherCutoutMips additions intact.

Add four-mode benchmark screenshots for the PR.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 15:41:21 +03:30
Ludovic MarieandClaude Opus 5.5 dd4b209ae7 lan: don't make room while holding the address map
make_room logs turned-away connections, and the log reads by_address:
count called it with that lock held, so the accept loop deadlocked the
first time the server filled with a log writer set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:06:19 +01:00
Ludovic MarieandClaude Opus 5.5 21249821f2 lan: sort the turned-away addresses by key (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:51:42 +01:00
Federico Tagliani 5ae8f36192 Merge remote-tracking branch 'origin/main' into fix/nightly-parity-regressions 2026-11-01 12:50:48 +01:00
Federico TaglianiandClaude Sonnet 5.5 f0590964bb Parity: a JVM oracle that dies without an exception says how it ended
decorated_region (through chunk_generation's oracle) failed in the nightly audit with
"oracle failed: " and nothing after it, and datafix with a CalledProcessError that
names the command; both Java oracles stopped with no exception to print. Show the exit
status (and the signal that killed it) and the last lines of its output.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:46:20 +01:00
SylviromiandClaude Opus 5.5 56d304c724 Data packs and content mods: remove and replace recipes by filter
A file data/<ns>/recipe_filter/<name>.json lists filters that match
recipes by id or type (with *), by an input or an output item, and
remove them or swap one input or output for another. They run on each
recipe's JSON after the packs are merged and its load conditions held,
so they reach every recipe type, mods' own included, in every loader:
crafting, the stations, brewing, the recipe book and the recipes the
players are sent. A filter with an unknown key or not exactly one
action is skipped with a warning, so a typo never matches every recipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:45:53 +03:00
oskal675-designandCodex GPT-6.1 278a1093ee Spawn eggs from source fluid item uses
Use the source-only fluid ray for SpawnEggItem.use, preserve packet-supplied block hits for useOn, and compare both paths against vanilla.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 16:42:07 +05:00
Ludovic MarieandClaude Opus 5.5 ea9b10f290 lan: a full server closes the connection that waited longest
When every place is taken, the oldest connection still before play
(handshake, status, login, configuration) that has waited more than 2 s
is closed to admit the new one. A crowd of sockets that never finish can
no longer keep players out; players in the game are never closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:41:42 +01:00
Alexander KrylovandHermes Agent 20f68001d8 Tests: build the smooth chat test's Chat in one go
Clippy's field_reassign_with_default on the new test.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:39:28 +03:00
Ludovic MarieandClaude Opus 5.5 21b8af4bf3 Server: one address takes at most 10 connection places; the login waits 10 s
The connection cap (#5776, players + 64) filled up on the official server
within minutes of a restart and every new join was reset. Now one public
address holds at most 10 connections (local and private addresses, where a
proxy or a port forward hands everyone over, stay unlimited), the handshake,
status and login phases wait 10 s for the client instead of 30 s, and once a
minute the log says how many connections were turned away and which
addresses held the most places.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:36:46 +01:00
Ludovic Marie c1f4a7c196 Merge pull request #5882 from horizon-rust/launcher-win-account-flaky
🤖 Agent: fixes the Windows-only account test flakes that blocked launcher 0.2.33 (per-store account lock). Verified: windows-latest green twice on this head (runs 41300356019 push, 41300386770 dry run: 352 passed), Mac mini account tests + clippy clean.
2026-11-01 12:36:29 +01:00
TeiwazikandClaude Opus 5.5 e2850986c9 Shader API: set_surface and surface_at, per-pixel data for the post stage
A material stage stores a normal, a smoothness and a value of its own for solid terrain; a post
pass reads them, where that terrain still shows. Only a stage that calls set_surface pays for the
target and its passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:34:46 +01:00
repositorykreml1n d2e4a36055 Aim a guest's attack at a visible point of the mob, so hits through a slab or trapdoor gap land
In single-player the player's attack goes through the integrated server as a guest attack. guest_attack calls
look_at_entity, which aimed at the centre of the target mob; Game::attack then drops the hit when a block outline is
nearer than the mob along that ray. The attack packet names only the entity, so a mob that the player's own crosshair
hits through a gap (a top slab, a closed top-half trapdoor, ...) had its centre hidden and the hit was dropped.

For mobs, look_at_entity now aims at the centre when it is visible, else at the visible point of a grid of points in
the mob's box nearest to the centre (nearer the eyes on ties), found with a clip_ray from the eyes. A mob whose whole
box is hidden keeps the centre, so a really blocked hit is still refused. Other entity kinds are unchanged.

Fixes #5772
2026-11-01 14:34:20 +03:00
SylviromiandClaude Opus 5.5 e6b365b01a Native mods: hear that the server loaded or reloaded its data
native_server_data_loaded(reload) fires once the server's data tables are
built: on a world's first open, before its chunks and players, and after
every /reload, once the players were sent the new data. NeoForge's
TagsUpdatedEvent with SERVER_DATA_LOAD. A mod drops caches derived from
the data and rebinds what depends on it there instead of polling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:29:52 +03:00
TeiwazikandClaude Opus 5.5 99eb871255 Shader API: the material stage reads its own block texture
block_texture, block_texture_level, block_texture_size and block_texture_lod read the surface's
sprite anywhere on it. The LabPBR _level functions now count the texture's mip levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:24:13 +01:00
Alexander KrylovandHermes Agent e1babb9132 Chat: a new line slides in and fades in (Smooth chat extra)
The ChatAnimation mod's idea, not its code: a line slides in from the left
while it fades in over 5 ticks instead of appearing whole, and the indicator
bar can be turned off. Both are Extras, off (the bar: on) by default, so a
vanilla client draws exactly what it drew before.

Chat::layout takes the fractional tick now, so the slide moves every frame
and vanilla's own fade does too. Only the unfocused HUD chat animates: with
the chat screen open the lines are drawn where click_at maps them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-01 14:23:56 +03:00
Ludovic MarieandClaude Opus 5.5 d44fb4367a Block states: shared property strings are the process's, not counted
Cloning a registry state bumped one Arc per name and value; world
generation threads clone the same states, and 20 bots cost 0.25 ms of
streaming. Shared strings are now interned once for the process (the
registries' names and values, never a player's) and copied as pointers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:22:01 +01:00
Ludovic Marie 9543d2a686 Merge pull request #5891 from horizon-rust/fix/prediction-log-where
🤖 Agent: pr_ready --quick green (1674 horizon-client tests, clippy, fmt); log format test updated; diagnostic only (log-line fields).
2026-11-01 12:16:22 +01:00
JoansitohandClaude Sonnet 5.5 d3fe2797d9 docs: list hideBlockParticles in the extras intro
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:10:10 +01:00
JoansitohandClaude Sonnet 5.5 645326f21e Primed TNT carries its Passengers
/summon minecraft:tnt {Passengers:[...]} dropped them. Load them as riders
(EntityType.loadEntityRecursive) and seat them on the TNT's top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:09:03 +01:00
Kacpherek ad4877f432 List gpu-passes in the horizon-ctl reference 2026-11-01 12:07:52 +01:00
levzzz fab6784831 Merge remote-tracking branch 'hr/main' into pr-5731
# Conflicts:
#	docs/native-mods.md
2026-11-01 16:06:56 +05:00
TeiwazikandClaude Opus 5.5 e241409e65 Clear a recycled mesh's light sources
SectionMesh::clear kept `lights`, so a mesh taken from the pool still listed the sources of the
section it last held: a broken torch stayed in the colour grid, and sources piled up elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:06:42 +01:00
levzzz 07b7ea30d0 Merge pull request #5739 from Stink-O/native/push-and-flying-speed
feat(mods): native mods can push the player and set their flying speed
2026-11-01 16:06:22 +05:00
levzzz 4d75ff55f9 Merge pull request #5617 from Sylviromi/screen-key-input
Native mods: screen_key_pressed and screen_char_typed, keys over the open screen (API next)
2026-11-01 16:06:18 +05:00
Ludovic MarieandClaude Opus 5.5 8f2e53a966 Launcher: give each memory store its own account lock
Every unit test shared the process-wide account operations lock. On Windows a connection
to the tests' closed port (127.0.0.1:9) takes about 2 s to be refused, and tests that hold
the lock meanwhile made another test's status read answer "busy" after its 3 s wait
(Retry instead of SignInRequired), or starved a 5 s lock_within behind blocked waiters.

The in-process lock now comes from SecretStore::operations: the system keychain keeps the
one process-wide lock (one keychain per computer), a MemoryStore has its own, as its
secrets are its own. The launcher uses a single store, so nothing changes for players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:37 +01:00
Ludovic MarieandClaude Opus 5.5 787a59fd85 Anticheat prediction: a log line says the tick's keys, where it ended and the replay's velocity
So a flag can be found again in the world and played there (#5879: water
flags that don't reproduce in the harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:05:04 +01:00
Ludovic Marie f4cd2e97a9 Merge pull request #5757 from Teiwazik/fix/cutout-mips-keep-coverage
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:05:03 +01:00
Ludovic Marie 393e3f0034 Merge pull request #5830 from Teiwazik/shader/post-targets
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:04:58 +01:00
JoansitohandClaude Sonnet 5.5 20c472f008 mcp: derive launcher PROTOCOL from SUPPORTED, tidy tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:04:20 +01:00
VaspyyyandClaude Opus 5.5 87fb637889 Advancement toast: draw the task/goal label yellow again (#5850)
The HUD batch takes colours packed as hud::rgba (red in the low byte);
0xFFFF_FF00 there is cyan. AdvancementToast draws the label -256 (yellow),
or -30465 (pink) for a challenge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:00:56 +01:00
Ludovic Marie db42d44415 Merge pull request #5819 from Teiwazik/shader/labpbr
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:34 +01:00
Ludovic Marie bfa86ce98d Merge pull request #5753 from Teiwazik/shader/lights
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:29 +01:00
Ludovic Marie 91c086865a Merge pull request #5871 from horizon-rust/fix/release-green-window
🤖 Agent: One-number change in tools/release_train.py (10 → 50 parity-main runs looked back); its 25 unit tests pass. Unblocks the Nightly train.
2026-11-01 12:00:25 +01:00
Ludovic Marie 719a7d97ed Merge pull request #5681 from Teiwazik/shader/textures
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:22 +01:00
Ludovic Marie 6ed842f35b Merge pull request #5663 from Teiwazik/shader/weather
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:18 +01:00
Ludovic Marie 5bd8da2db5 Merge pull request #5652 from Teiwazik/shader/pack-pages
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:14 +01:00
Ludovic Marie 66bf45924c Merge pull request #5658 from Teiwazik/shader/sky-flags
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:11 +01:00
Ludovic Marie ad3d1ac77d Merge pull request #5824 from Teiwazik/shader/material-fog
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:07 +01:00
Ludovic Marie 411202c6bb Merge pull request #5814 from Teiwazik/shader/texture-budget
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 12:00:03 +01:00
Ludovic Marie 46716474ef Merge pull request #5827 from Teiwazik/shader/stage-files
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:59 +01:00
Ludovic Marie 4fbe3bf2dd Merge pull request #5619 from Teiwazik/shader/s9-materials
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:55 +01:00
Ludovic Marie 294097d298 Merge pull request #5590 from Teiwazik/shader/s8-shadow-entities
🤖 Agent: Shader series stacked in order (#5590 → … → #5757), each head clippy/test/fmt-clean on the Mac mini, the union checked in game with an all-keys pack and without a pack (vanilla). Merging once CI and parity are green.
2026-11-01 11:59:51 +01:00
Stink-O 55f58be289 Merge #5617's approved head into #5741 2026-11-01 10:58:58 +00:00
Stink-OandClaude Opus 5.5 13327b6e0a refactor(mods): the mod screen's Game field after native_net
So it no longer sits on the lines #5731 adds its field to: the two merge in either order without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:40 +00:00
Ludovic MarieandClaude Opus 5.5 7a6ff6cc32 Release train: look back 50 parity-main runs, not 10
Every merge cancels the parity-main run before it. In a busy hour of merges, the
last finished run fell out of the 10-run window, green_main_head found nothing,
and no Nightly went out for 14 h (2026-11-01) although parity-main had passed
on bd8c53bfa.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:57:49 +01:00
JoansitohandClaude Sonnet 5.5 ad462ede29 fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:57:47 +01:00
oskal675-designandCodex GPT-6.1 7b2497f52c Keep item use ahead of later player movement
Preserve the throw origin and inherited movement by preventing queued movement from overtaking a deferred UseItem. Compare standing and gliding throws against a real vanilla server.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 15:56:52 +05:00
levzzz e918209069 Merge remote-tracking branch 'hr/main' into pr-5783 2026-11-01 15:53:01 +05:00
TiggerTakkyTTTandCodex 2daf81f487 Fix filled shulker box drops in Creative
Preserve collected components when breaking filled shulker boxes in Creative,
including with block drops disabled. Check all variants against vanilla through
the integrated server player-break path.

Fixes #5821

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 10:51:32 +00:00
levzzz af669895a8 Merge pull request #5792 from CocofireHD/feature/mod-particle-types
Native mods: a mod's own particle types
2026-11-01 15:50:47 +05:00
levzzz 863b9034e7 Merge pull request #5780 from lukehinojosa/feat/native-lead-drops
Native mods: lead drops through native_loot_drops
2026-11-01 15:50:43 +05:00
levzzz f10724e8b5 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:48:10 +05:00
levzzz 9203ebc1b8 Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:48:10 +05:00
levzzz 2f077e899b Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:48:10 +05:00
Jinuk Chanthusa 39c90ba242 Fix parity remote startup failures
Fix parity remote startup failures
2026-11-01 16:17:49 +05:30
JoansitohandClaude Sonnet 5.5 5cd320b24c Extras: hideBlockParticles hides block-break particles
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:47:25 +01:00
JoansitohandClaude Sonnet 5.5 0f3ee35c21 Open the on-screen keyboard in pointer menu mode
Part of #5642.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:45:43 +01:00
Ludovic MarieandClaude Opus 5.5 eecf134cec Merge the shader series up to #5830 into #5757
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5830 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 606df956bf Merge the shader series up to #5819 into #5830
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5819 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:49 +01:00
Ludovic MarieandClaude Opus 5.5 e08bc408a3 Merge the shader series up to #5753 into #5819
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5753 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:44 +01:00
Ludovic MarieandClaude Opus 5.5 83966d3fb3 Merge the shader series up to #5681 into #5753
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5681 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:40 +01:00
VaspyyyandClaude Opus 5.5 76ba2858e9 Native mods: music a client mod chooses and layers (native_music, native_music_layers)
`native_music(music, volume)` changes the track the music manager plays
and its volume each tick (Minecraft.getSituationalMusic and
getMusicVolume; NeoForge SelectMusicEvent). `native_music_layers(layers)`
lists looping tracks played over it on the music source, each easing to
its own volume by 0.025 a tick; layers new in the same tick start
together, so a piece's stems stay in step and can grow fuller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:42:32 +00:00
Ludovic MarieandClaude Opus 5.5 5e967a2730 Merge the shader series up to #5663 into #5681
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5663 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 13eeb8147b Merge the shader series up to #5652 into #5663
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5652 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 76e7861108 Merge the shader series up to #5658 into #5652
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5658 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:20 +01:00
levzzz bb03a6b2e0 Merge pull request #5748 from lukehinojosa/feat/native-give-experience
Native mods: read experience orbs, give experience and levels
2026-11-01 15:42:19 +05:00
Ludovic MarieandClaude Opus 5.5 6d2f22af83 Merge the shader series up to #5824 into #5658
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5824 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea73dab8ce Merge the shader series up to #5814 into #5824
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5814 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 76154d843f Merge the shader series up to #5827 into #5814
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5827 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 aa125d76a7 Merge the shader series up to #5619 into #5827
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5619 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:53 +01:00
Ludovic MarieandClaude Opus 5.5 2f5862e0e3 Merge the shader series up to #5590 into #5619
The PRs before this one in the series, merged in order with their conflicts resolved,
so this branch merges cleanly once #5590 has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:48 +01:00
Ludovic MarieandClaude Opus 5.5 df761058da Merge main into #5590
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:41:43 +01:00
JoansitohandClaude Sonnet 5.5 911aebaaf2 mcp: answer initialize with a protocol version we support (part of #5673)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:19 +01:00
JoansitohandClaude Sonnet 5.5 d6c1bf1791 Recover bitmap font glyphs when the dynamic atlas region fills up
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:07 +01:00
Ludovic Marie d7a41d6ff3 Merge pull request #5856 from horizon-rust/fix/prediction-join-freeze
🤖 Agent: pr_ready --quick all green (1671 horizon-client tests, clippy, fmt); new replay test + 16 existing pass; anticheat_e2e --check prediction: honest client never flagged, cheating bot flagged 13x and sent back 12x.
2026-11-01 11:41:06 +01:00
Lostpold88andAntigravity 6548234ac5 Bow arrows no longer drift when strafing and remote arrows update flight pitch
When charging a bow while strafing on the ground, arrows drifted sideways
because player ground movement was added to the projectile velocity and
rotation was not aligned to the resultant vector. In addition, client-side
remote arrows did not update their pitch and yaw during flight, causing
them to fly horizontally like static lasers without following their
parabolic trajectory.

Align bow release with vanilla behavior by not injecting ground strafe
velocity and updating rotation in `shoot_from_rotation`. In
`remote_arrow_tick`, update pitch and yaw every tick using `lerp_rotation`
matching `AbstractArrow.tick()`.

Fixes #5855.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 11:40:59 +01:00
levzzz 08e734236d Merge remote-tracking branch 'hr/main' into pr-5780 2026-11-01 15:38:54 +05:00
VaspyyyandClaude Opus 5.5 1cdc20c2cc Native mods: biome colours a client mod changes, for seasons (native_biome_colors)
`native_biome_colors(biome, colors)` changes a biome's grass, foliage,
dry foliage and water tints. It is asked for every biome when a mod calls
`native_refresh_biome_colors()`, which swaps the mesh workers' biome table
and rebuilds the loaded sections and far terrain when the colours differ
(LevelRenderer.allChanged, as a Biome Blend change does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:38:04 +00:00
levzzz 3e32651f45 Merge pull request #5763 from lukehinojosa/feat/native-interaction-drops
Native mods: player-use drops through native_loot_drops
2026-11-01 15:37:55 +05:00
levzzz 85072be4af Merge pull request #5815 from Detimes/feat-5682-console-native-commands
Native mods: a registered command typed on the dedicated server's console (part of #5682)
2026-11-01 15:37:51 +05:00
levzzzandClaude Opus 5.5 24f539c633 Content mod docs: every kind it declares, multiplayer, the sounds key, sections in place
Closes #5852.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:36:58 +05:00
Stink-OandClaude Opus 5.5 c107eeb1a4 fix(mods): a mod screen opens only where the screen hooks can draw it
native_mod_screen_open refuses what screen_hooks_open would: an overlay that leaves the screen at Playing (a
book, a sign editor, a dialog, a prompt), the world loading, no player, and anywhere but the window's own client
(no renderer, or a LAN guest's swap on the server thread), so a screen never opens that nothing could draw or
close. The docs and fragment say so, and that a gamepad can close it but not click on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:33:46 +00:00
VaspyyyandClaude Opus 5.5 91e754dd88 Native mods: draw a mob with the mod's own model (native_mob_submit)
A client mod is asked once a frame for each mob in view (players aside)
with the mob's render state: id, kind, custom name, baby, partial tick,
age, body and head turns, pitch, walk cycle, hurt and death time. It draws
with the block entity drawing helpers under a pose at the mob's feet,
turned with its body, and sets `drawn` to leave out the vanilla model
(EntityRenderer.submit; NeoForge RenderLivingEvent.Pre, cancelled).
Name tag, shadow, fire and glowing outline still draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:32:50 +00:00
Kacpherek c627a02c56 Pass the timing option on without re-borrowing it 2026-11-01 11:32:38 +01:00
levzzz dcbfa2e3ed Merge remote-tracking branch 'hr/main' into pr-5748
# Conflicts:
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-01 15:32:28 +05:00
Ludovic MarieandClaude Opus 5.5 b6709fee4f Block states: facts.json reads properties straight into shared strings
The registry's states were read into a BTreeMap of owned strings, then
shared: two allocations and a hash per string, 50 ms of the start. The
facts.json decoder now shares each name and value as it reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:21 +01:00
levzzz 59744ec017 Merge pull request #5661 from lukehinojosa/feat/native-give-stack
Native mods: give a whole stack to any player (native_give_stack)
2026-11-01 15:31:38 +05:00
levzzz 4afb288b43 Merge pull request #5770 from d3xc0/native-hooks
Native mods: read redstone signal, light, weather, time, world height and stack size
2026-11-01 15:31:34 +05:00
Kacpherek 9025a1ac46 Show Horizon's own GPU pass times in F3 and the control socket
The renderer's own pass times were not reachable from the client. The F3
overlay now shows them under their own heading, next to the shader pack
group. A new gpu-passes control command times them without opening F3,
and the stats and bench summaries report them as gpu_passes.
2026-11-01 11:30:37 +01:00
Kacpherek 3e9805e295 Time Horizon's own render passes on the GPU
The per-pass GPU timer only covered shader-pack passes, so the F3
overlay could not say which of Horizon's own passes cost the frame.
Give the same timer to the frame's own passes (world, occlusion test,
OIT, HUD, post, HDR, hand, outline, menu blur, native shadow) and add
compute_writes for compute passes.

Passes that already carry timestamps (shader packs, the frame timer),
screenshots, one-off clears and test code are left as they were. The
timer begins at the top of the frame so that passes recorded before
the first draw, such as sprite animation, are timed too.
2026-11-01 11:30:37 +01:00
Stink-O 713798e681 Merge main into #5741 (after #5616) 2026-11-01 10:30:34 +00:00
Stink-O ad13187cf7 Merge #5617's current head into #5741
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 10:30:34 +00:00
levzzz 02197e6053 Merge remote-tracking branch 'hr/main' into pr-5815
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 15:28:07 +05:00
levzzzandClaude Opus 5.5 3892152091 Lead killers: each level prunes only its own corpses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:58 +05:00
levzzz 51f26d0576 Merge pull request #5825 from Detimes/feat-player-command-hook
Native mods: any command a player types, cancellable (native_player_command, part of #5816)
2026-11-01 15:27:46 +05:00
Ludovic MarieandClaude Opus 5.5 0626d6d505 Anticheat prediction: a client still loading the level isn't flagged for not falling
Until the client says it loaded the level (at join and after a respawn), the
ticks it ends with no move are excused: it is on its loading screen, where it
doesn't move, even once the server stops waiting for it after 60 ticks. Its
first move counts as any other.

Fixes #5849.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:27:33 +01:00
SylviromiandClaude Opus 5.5 4a02c7d5b9 Native mods: a contributed data pack file that isn't JSON is skipped alone
One bad file from a mod would fail its whole table and drop the world's
pack content with it; it is now skipped with a warning. The hook's docs
list every kind the tables read, trade sets, villager trades and dialogs
included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:26:24 +03:00
Ludovic Marie d8dc15794c Merge pull request #5847 from horizon-rust/perf/startup-sp2
🤖 Agent: playhorizon 4 CPUs, 6 alternated starts: Done 2.067 → 1.881 s median (6/6), core tables 0.70 → 0.52 s, idle RSS same; pr_ready tests 1702 + content roots pass, fmt/clippy/Python clean on the Mac.
2026-11-01 11:23:54 +01:00
Ludovic Marie de552471f6 Merge pull request #5848 from horizon-rust/fix/main-build-attributes-after-first-batch
Server: a joining player's attributes wait for its first chunk batch (dedicated_server flake)
2026-11-01 11:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 d10b514182 Server: a joining player's attributes wait for its first chunk batch
parity-main fails on dedicated_server now and then: "+play-start
minecraft:update_attributes". #5597 held the join tick's dirty attribute
instances for one tick, because vanilla syncs them on the next tick, after
the first chunk batch. But this server sends no batch before placed_at + 2,
and only once the player's chunks are ready: on a loaded runner the first
batch came after that next tick, so the attributes went out before it.
They now wait until a batch has been sent (unacknowledged > 0, or the
client already acknowledged one), which keeps vanilla's order however long
the chunks take.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:22:24 +01:00
levzzz f9187fe58b Merge pull request #5707 from ErikKalikas/mod-block-entity-keep
Native mods: keep a mod block entity's geometry while it doesn't change
2026-11-01 15:21:38 +05:00
levzzzandClaude Opus 5.5 aa21d36fff Launcher: translate the native builds' In use and section labels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:27 +05:00
levzzzandClaude Opus 5.5 8bb24b5292 Claimed items: an off-hand claimed item sends no use; docs: main hand only, the movement check, claims over use durations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:15:18 +05:00
levzzzandClaude Opus 5.5 d3fb451a44 Flying speed: the server's movement replay uses it; docs: client pushes and the anticheat, false inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:50 +05:00
levzzzandClaude Opus 5.5 8560cb7287 Creative search: a number key the hovered slot took types nothing; scroll hook doc: discrete scrolling, scroll-at
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:14:14 +05:00
levzzzandClaude Opus 5.5 5b4333059f Mod particle types: an API fragment instead of a hand-bumped number; say vanilla clients aren't sent them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:13:43 +05:00
levzzz 5c0ee1ef23 Merge #5763's branch (fixed) into the lead drops 2026-11-01 15:12:40 +05:00
levzzzandClaude Opus 5.5 a9c6d86b39 Spill: a use whose loot came out empty still reaches the hook and is drained
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:34 +05:00
levzzzandClaude Opus 5.5 4e40cd0eb4 Experience docs: no level-up sound for levels, when a give can be refused, merged orbs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:26 +05:00
levzzzandClaude Opus 5.5 079292b669 Give stack: reach no other player inside a player's hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:17 +05:00
levzzzandClaude Opus 5.5 e7fedb9bc3 World reads: no light in unloaded chunks, no weather outside the overworld, 26.3's clock name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:12:05 +05:00
levzzzandClaude Opus 5.5 723299c6ab Console mod commands: look the line up trimmed, as the console reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:40 +05:00
levzzzandClaude Opus 5.5 2a1b8a321a Player command hook: hear quiet command batches, name it canceled, catalog keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:11:31 +05:00
levzzzandClaude Opus 5.5 c3af108912 Clear kept mod block entity geometry with the world; say what keep() freezes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:10:58 +05:00
levzzz 4ef0afc99c Merge remote-tracking branch 'hr/main' into pr-5707
# Conflicts:
#	crates/horizon-client/src/game/native_block_entity_draw.rs
2026-11-01 15:10:58 +05:00
levzzz a75f58462f Merge remote-tracking branch 'hr/main' into pr-5783 2026-11-01 15:10:25 +05:00
levzzz 4359398e57 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:10:25 +05:00
levzzz 648a65259d Merge remote-tracking branch 'hr/main' into pr-5739 2026-11-01 15:10:25 +05:00
levzzz bbc31dd472 Merge remote-tracking branch 'hr/main' into pr-5617 2026-11-01 15:10:24 +05:00
levzzz e0f5ad643b Merge remote-tracking branch 'hr/main' into pr-5792 2026-11-01 15:10:24 +05:00
levzzz 6d744151ac Merge remote-tracking branch 'hr/main' into pr-5763 2026-11-01 15:10:24 +05:00
levzzz 50069df898 Merge remote-tracking branch 'hr/main' into pr-5748 2026-11-01 15:10:24 +05:00
levzzz a69a65d099 Merge remote-tracking branch 'hr/main' into pr-5661 2026-11-01 15:10:24 +05:00
levzzz 7239b38b1d Merge remote-tracking branch 'hr/main' into pr-5770 2026-11-01 15:10:24 +05:00
levzzz 2b7549c556 Merge remote-tracking branch 'hr/main' into pr-5815 2026-11-01 15:10:23 +05:00
levzzz 9802a6dccb Merge remote-tracking branch 'hr/main' into pr-5825 2026-11-01 15:10:23 +05:00
levzzz bd8c53bfa4 Merge pull request #5616 from Sylviromi/screen-mouse-scrolled
Native mods: screen_mouse_scrolled, the wheel over the open screen (API next)
2026-11-01 15:09:48 +05:00
levzzz afac2f98eb Merge pull request #5729 from Stink-O/native/clock
feat(mods): native mods can read the world's clock
2026-11-01 15:09:42 +05:00
levzzz aa85e03ee2 Merge pull request #5687 from ErikKalikas/mod-block-entity-draw-item-helpers
Native mods: draw items in mod block entities, read their block state's properties
2026-11-01 15:09:35 +05:00
Stink-OandClaude Opus 5.5 66ac20304e refactor(extras): the UI sound reserve's match arms on lines of their own
So #5675 (splitscreen), which edits the same two arms, merges with this in either order; only the one-line
serialize format string still meets it (keep both keys).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:03:54 +00:00
Ludovic MarieandClaude Opus 5.5 49e08de26d Block states: a palette entry's properties collect in one pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:59:22 +01:00
Ludovic MarieandClaude Opus 5.5 e7f99e7ce1 Block states: compact properties with shared strings
A block state's properties were a BTreeMap<String, String>: a 544-byte
leaf and two allocations per property for each of the registry's 35,723
states (about 30 MB of a dedicated server's live heap), for 237 distinct
strings. BlockProperties keeps them as a sorted slice of pairs, read like
the map (get, insert, iter, indexing, order, equality and hashing pair by
pair); the registry shares its states' strings through one Arc each.
States made elsewhere (chunk palettes, commands) own theirs, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:14 +01:00
CocofireHD 42aec3c9f6 Native mods: keep mod particles off vanilla clients, pack their sprites 2026-11-01 10:46:01 +01:00
TeiwazikandClaude Opus 5.5 4ee84d5fa6 Shader API: [[post]] output and scale, a pack's own targets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:38:07 +01:00
Ludovic MarieandClaude Opus 5.5 bef4061052 Block facts: validate the JSON beside the decode
The gameplay facts load parses its 56 MB file twice: a validation pass that
only keeps the JSON errors, then the typed decode. The validation now runs
on a scoped thread while this one decodes, and is joined before the load
returns, so its error still wins. With a single worker (`parallel` false)
the two passes run one after the other as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:33:35 +01:00
885a33e930 Native mods: the world options boxes type a key's characters only
The window now hands a key's characters to the character hook instead of
the key, so `world_options_key` must not read the missing text as a
backspace: only a key that carries text types, and a `None` stays the
control socket's `\b`.

The character hook fires after the key's screen effects, as GLFW's
separate char callback (`KeyboardHandler.charTyped`) comes after its key
callback, and its characters survive a cancelled press, as NeoForge's
`CharacterTyped.Pre` comes after a cancelled `KeyPressed.Pre`.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 12:31:27 +03:00
TeiwazikandClaude Opus 5.5 a8b2fe2969 Shader API: a stage's files, WGSL only that stage reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:27:10 +01:00
SylviromiandClaude Opus 5.5 f75040061d Native mods: a key's characters reach the screen after the key has acted
GLFW's char callback comes after its key callback, so the character
hook now fires once the screen has acted on the key, on the screen open
then: a key that closes the screen reports no character, and one that
moves the focus types into the box it moved to. Options keeps its text
on the key, as its key path routes it between boxes and key binds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:26:44 +03:00
levzzz f77b1391eb Merge pull request #5649 from reservedbytes/feat/native-item-use-over-time
feat(mods): native mods use items over time (duration, animation, can eat, start and finish)
2026-11-01 14:26:32 +05:00
TeiwazikandClaude Opus 5.5 4bb3e393d9 Shader API: [material] fog, for a pack that fogs its surfaces itself
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:23:34 +01:00
levzzz 3477f0d8ce Merge pull request #5692 from reservedbytes/feat/content-entity-types-save-summon
feat(mods): content mods' thrown entity types are saved, summoned and listed
2026-11-01 14:19:27 +05:00
levzzz b6bf49c1a9 Merge pull request #5809 from Xmilon/native/item-decorations
feat(mods): custom item icon decorations
2026-11-01 14:16:31 +05:00
levzzz b0358127e6 Merge pull request #5806 from Xmilon/native/client-resource-reload
feat(mods): notify clients after resource-pack reloads
2026-11-01 14:16:25 +05:00
Detimes 64c3a68a71 Native mods: any command a player types, cancellable (native_player_command) 2026-11-01 09:15:18 +00:00
levzzz 4e25d33e58 Merge pull request #5802 from Xmilon/native/5797-render-hand
feat(mods): cancellable first-person hand rendering
2026-11-01 14:15:16 +05:00
TeiwazikandClaude Opus 5.5 e7aa7cb796 Shader API: the material stage reads resource packs' LabPBR maps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 10:08:39 +01:00
levzzzandClaude Opus 5.5 b91ef70301 fix(mods): a use's client countdowns follow its started duration
LivingEntityUseItemEvent.Start may change a use's duration, which
start_using_item keeps in use_duration (vanilla's useItemRemaining). The
first person pose asked getUseDuration again each frame for the remaining
ticks, and a server's client counted a food's crumbs from its consumable
time: both now count from use_duration, as getUseItemRemainingTicks does.
A use the server's flags start on the client takes getUseDuration, as
onSyncedDataUpdated does.

The fragment carries the catalog's neoforge, side and cancellable keys,
which the stamp tool now copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:06:29 +05:00
dill-lkandCopilot d34380935d Fix parity remote startup failures
Co-authored-by: Copilot <[email protected]>
2026-11-01 09:05:53 +00:00
simonandClaude Opus 5.5 048a9276d8 The game can keep under a RAM limit
HORIZON_MEMORY_LIMIT_MB and /ramlimit cap the RAM the game holds. On
Windows a hard working set limit enforces it, so a low limit costs speed
instead of crashing. Elsewhere, and when the game is slow and faulting
hard, far terrain and the render distance in use step down and come back.
The game crashes with a report only if it can't run under the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:48 +10:00
simonandClaude Opus 5.5 3ca72c5088 Launcher: a Maximum memory row on Home
A slider and a typed box, saved per instance. Horizon's limit defaults to
2048 MB and reaches the game as HORIZON_MEMORY_LIMIT_MB; no limit is a
choice of its own. A Java instance's row sets -Xmx.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:05:48 +10:00
Detimes 8187bdafaf Native mods: a player respawning (native_player_respawned) 2026-11-01 09:04:22 +00:00
levzzzandClaude Opus 5.5 69722dfc5e Merge main into feat/native-item-use-over-time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:04:09 +05:00
levzzzandClaude Opus 5.5 a77659a199 fix(mods): mod entity rows appended as text; the summon test compiles
The content root appended each mod type's entity-types.json row by parsing
and rewriting the table, which needed serde_json's float_roundtrip to keep
a zombie's movement_speed to the bit. Cargo unifies that feature over every
crate in the game build (worldgen JSON included), while horizon-world's
parity probes, built alone, would parse without it. The rows are now
appended as raw text copied from the template's row, so no vanilla float
is reparsed and the feature goes.

The save test compared p.kind by value, then borrowed p: ProjectileKind
isn't Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:03:48 +05:00
SylviromiandClaude Opus 5.5 791ccd18da Native mods: screen_render_post skips the screen the server pack prompt hides
While PackConfirmScreen is open the frame draws no screen under it, but
the render hook still reported the hidden one, so a mod drew over a
screen nobody saw. It now skips that frame, as the input hooks already
do through screen_over_playing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:03:21 +03:00
levzzz ac68376123 Merge remote-tracking branch 'hr/main' into pr-5692 2026-11-01 14:02:39 +05:00
Detimes 678eecf01c Hook catalog fragment: name without the native_ prefix (#5682) 2026-11-01 09:02:34 +00:00
levzzzandClaude Opus 5.5 ccc8d4a9ae Merge main into native/item-decorations; the batch takes GUI units
The stub moves next to native_item_bar: #5804's block highlight took the
spot after native_tick. The docs said the batch uses screen coordinates;
it takes the same GUI units as x and y (draw_slot_item fills there too).
They now also list the other screens that draw item icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:16 +05:00
levzzzandClaude Opus 5.5 402bb0be5e Merge main into native/5797-render-hand; the hook only hides
The stub moves next to native_camera_shake: #5804's block highlight took
the spot after native_tick. Its docs no longer say mods can draw their own
hand geometry: the hook passes no vertex buffer, pose or light.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:02:00 +05:00
levzzzandClaude Opus 5.5 f7dc1bd0d5 Merge main into native/client-resource-reload
The hook stub moves next to native_frame: #5804's block highlight took
the spot after native_tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:01:40 +05:00
Stink-OandClaude Opus 5.5 7096f3fe39 fix(mods): save a player's flying speed and document the motion helpers
Vanilla saves Abilities.flyingSpeed with the player, so a speed a server
mod set now goes into abilities.flySpeed and comes back on the next join;
a file's own speed is read. Every abilities packet comes from one
Guest::abilities, so the join, a game mode change, a respawn and a level
change all carry it (tested with the save round trip). The fragment is
renamed to native_motion for both helpers, FLYING_MAX says why 50 and what
the movement checks do with a fast non-owner, the guest comment names
the server mod, the fork wording goes, and docs/native-mods.md gets a section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:00:42 +00:00
levzzz a1fa1ac0a6 Merge pull request #5804 from Xmilon/native/block-highlight
feat(mods): cancellable targeted block highlights
2026-11-01 14:00:40 +05:00
levzzz e79b7e80d8 Merge pull request #5713 from reservedbytes/feat/mod-menu-types
feat(mods): mod menu types with their own slot count and data slots
2026-11-01 14:00:36 +05:00
levzzz 7ac08d8634 Merge pull request #5677 from arismoko/fix/native-tell-acting-player
Fix native chat to the acting player inside hooks
2026-11-01 14:00:31 +05:00
levzzz 9adc39ae7a Merge pull request #5796 from Xmilon/native/5672-cancelled-click-focus
fix(mods): cancelled screen clicks preserve navigation focus
2026-11-01 14:00:26 +05:00
Sylviromi 9d255bbdf0 Merge branch 'screen-mouse-scrolled' into screen-key-input
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 11:58:47 +03:00
Stink-OandClaude Opus 5.5 d0135bf354 fix(mods): native_clock with then_some (clippy unnecessary_lazy_evaluations)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:57:42 +00:00
SylviromiandClaude Opus 5.5 adb8f735ec Native mods: a cancelled key still types the characters left, and the socket's backspace stays a key
A key press a mod cancelled hands the characters no mod cancelled to the
text box, as vanilla's separate charTyped does; the control socket's
type \b no longer offers its backspace sentinel as two characters, and
its cancelled tap sends the release the docs promise. The key and
character hooks' docs name the control socket and say a key that closes
the screen reports its character there first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:56:13 +03:00
Sylviromi e69ed14ade Merge remote-tracking branch 'origin/main' into screen-mouse-scrolled 2026-11-01 11:49:37 +03:00
Detimes 7176677496 Native mods: a registered command typed on the dedicated server's console (#5682)
native_perform_console_command hears a command a mod registered when it is typed on the
dedicated server's console (level 4, no player), before vanilla's dispatcher; the answer goes
to the console through native_feedback. The single player owner's console is not covered.
2026-11-01 08:45:06 +00:00
TeiwazikandClaude Opus 5.5 5445cf8a52 Graphics setting: textureBudgetMiB, the block textures' video memory
The block sprite array was held to a fixed 256 MiB, which samples a
high-resolution pack down to 128 px. The default stays that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 09:36:57 +01:00
Ludovic Marie b4e123280d Merge pull request #5810 from horizon-rust/fix/main-build-random-tick-push-up
random_tick: the probe skips push_entities_up (parity-main green again)
2026-11-01 09:33:02 +01:00
Ludovic MarieandClaude Opus 5.5 48af02d0a7 random_tick: a block's push_entities_up lifts nobody in the probe's cases
Since #5100, turn_to_base_block (a dirt path or farmland turning to dirt)
emits a push_entities_up block event, which the block-update probe printed
as "X push_entities_up". Vanilla's Block.pushEntitiesUp only moves the
entities in the new block's box, and these cases have none, so the oracle
logs nothing: the dirt_path case differed and parity-main failed. The probe
now skips it, as it skips the other events that need someone there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 09:32:19 +01:00
[email protected]andCodex c411dcd917 feat(mods): append custom item icon decorations
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:25:36 +01:00
[email protected]andCodex b87b52e3f3 feat(mods): notify clients after successful resource reloads
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:23:36 +01:00
[email protected]andCodex f4d6ebab0c feat(mods): allow cancelling targeted block highlights
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:21:41 +01:00
Ludovic Marie 7e5e3d1ada Merge pull request #5799 from horizon-rust/perf/ticks-fx-hash
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_blocks 5.35→5.06 ms (−5.5 %), MSPT 25.91→25.46. Tick sets only looked up (keys sorted where listed). fmt + clippy -D warnings + horizon-core tests pass (Mac mini); 955 render+core tests pass (playhorizon); its 2 Python failures are its VSCREEN env.
2026-11-01 09:19:12 +01:00
[email protected]andCodex f9e03cd32b feat(mods): add cancellable first-person hand rendering
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:19:01 +01:00
[email protected]andCodex 47a342e6c9 fix(mods): keep cancelled screen clicks from moving navigation focus
Co-Authored-By: Codex <[email protected]>
2026-11-01 09:14:33 +01:00
Stink-OandClaude Opus 5.5 24067ef56f fix(mods): end a claimed item's use wherever the game resets the use
A respawn, a join, the hand's item changing or the server stopping the use
reset the player's use without the mod use knowing, so the next vanilla
use was taken for the mod's and cancelled. The mod use now records the
slot and ticks it runs at, only that use is the mod's, and every reset
sends the mod its UseStop. native_held_item goes (native_held gives the
held id; the use events carry the custom name and data), as do the unused
screen events and the fork's wording; docs/native-mods.md gets a section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:12:36 +00:00
Stink-OandClaude Opus 5.5 eb47e82bac fix(mods): native_quad fills as native_fill does; the mod screen's rules tested and documented
native_quad draws through HudBatch::fill_quad (the HUD atlas's white, as native_fill) instead of borrowing the
lightning renderer's white_uv. The rules for opening a mod's own screen move into mod_screen_opens, with a test:
only over the world, never over another screen, the title screen or another mod's, and again a no-op. The docs
say every mod's own screen shares the id `mod` (check native_mod_screen() is yours) and that it doesn't pause a
single-player game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:10:45 +00:00
Stink-OandClaude Opus 5.5 09a73636d0 fix(mods): native_clock gives the clock as the world keeps it
Renamed from native_get_clock (no other helper has a get_ prefix). Both fields are i64 ticks as
Level.getGameTime and getDayTime return them, without the clamp to 0: the day time counts on past 24000
and /time set can make it negative, and the docs say so. Documented as the shown world's clock, for
client mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:07:17 +00:00
Stink-OandClaude Opus 5.5 19a4e7c31c feat(extras): the UI sound channel reserve is an extra, off by default
With it off the static pool is vanilla's again (247 channels to whatever starts first), so the sound_engine
parity harness is unchanged; `uiSoundReserve` (Extras > Sound, "Keep channels for UI sounds") turns on the 16
channels positional sounds leave for the ones played to the listener. The test checks both, and its
`use test_backend::Null` moves into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:02 +00:00
Stink-O 047b17f57b Merge main into #5686 2026-11-01 08:00:19 +00:00
Stink-O dd658dc0b3 Merge main into #5685 2026-11-01 08:00:18 +00:00
Stink-O 8b9556cc9d Merge main into #5739 2026-11-01 08:00:13 +00:00
Stink-O 764cbf8214 Merge main into #5731 2026-11-01 08:00:13 +00:00
Stink-O 670584316e Merge main into #5741 2026-11-01 08:00:13 +00:00
Stink-O 54335a438d Merge main into #5729 2026-11-01 08:00:13 +00:00
Ludovic Marie 5edb339a76 Merge pull request #5793 from horizon-rust/perf/chunk-encode-palette-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_lan 4.22→3.96 ms (−6 %), MSPT 25.65→25.32. Same bytes (new test vs from_values). fmt + clippy -D warnings pass (Mac mini); workspace tests 3982 pass (playhorizon); the 2 Python failures there are its VSCREEN env, they pass on the Mac mini.
2026-11-01 08:55:26 +01:00
CocofireHDandDeepSeek Harness a7bda85c03 Native mods: a mod's own particle types
A native mod can now send a particle of its own type, drawn by every client
from the sprite set the mod's pack ships, instead of borrowing a vanilla one.

The type is the mod's namespaced name in ParticleOptions::named ("mymod:spark");
the client builds it as vanilla's SimpleParticleType with a sprite set: a
plain billboard, no tint, 20 to 40 ticks, on the engine's own random. The set
comes from assets/<namespace>/particles/<name>.json, the file vanilla's
SimpleParticleType reads; its textures resolve as particle/<texture> in the
atlas, the same rule vanilla's own particle JSONs use.

On the wire a mod particle is Horizon's own extension: the sentinel id
MOD_PARTICLE_ID (-1, never a vanilla registry id) followed by the string,
which vanilla's Particle codec has no room for. A client that does not know
the mod ignores it like any unknown particle, and native_send_particles
refuses an unnamespaced name.

native_send_particles keeps its signature; a mod builds the options itself,
so no hook point moved. NATIVE_HOOKS_API is 22 for the new capability and
docs/native-mods.md says what is supported.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-01 08:52:10 +01:00
Kacpherek ba01d1e9f7 Draw the ender dragon in every dimension, not only the End
The dragon was drawn only while the player stood in the End, so a
dragon summoned in the Overworld (/summon ender_dragon, which the
server sends to the client as an AddEntity) was heard, flapping and
growling, but never shown. Vanilla's EnderDragonRenderer draws an
EnderDragon in any dimension like any other entity; the gate was
added with the fight, when the dragon only ever existed in the End.
The dragon fireballs share the same block and are now drawn in every
dimension too.
2026-11-01 08:38:50 +01:00
Ludovic MarieandClaude Opus 5.5 d16d57b197 Scheduled ticks: FxHash for the per-tick position sets
LevelTicks and ChunkTicks hash a (type, position) key at every schedule,
poll and dedup check, and the chunk map at every lookup, with std's SipHash.
They are only looked up, never iterated in order (the keys of an area are
sorted), so they now use the Fx hasher, which moves from
horizon-client-render to horizon-core (re-exported under the old path).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:35:53 +01:00
Detimes d172784bdd docs: a player's arrow hurts other players on a served world (#5785)
The server doc still listed bow PvP between guests as missing; it works since 5763854b0 (lan_e2e 'arrows'), and a dedicated server run with two protocol clients agrees.
2026-11-01 07:33:48 +00:00
Ludovic Marie 78043f09a9 Merge pull request #5787 from horizon-rust/fix/client-input-auto-jump
🤖 Agent: #5786. Auto-jump now in ServerboundPlayerInputPacket as vanilla's keyPresses. Staircase walk with autoJump on, same server: 75 prediction flags with main's client, 0 with this one. pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 17410e0123 Client: the input packet carries the auto-jump, as vanilla's does
LocalPlayer.sendChanges sends input.keyPresses, which an auto-jump's makeJump
sets to jump for that tick. Horizon sent the keys as held, so with Auto-Jump on
the server saw jumps its keys didn't allow: the anticheat's prediction flagged
every auto-jump and the 0.4 a tick it left (75 flags on a staircase walk, none
with this change). The official server's log has the same lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:32:09 +01:00
Stink-O 07901bae47 Merge main into #5741 (both sides' hook tests kept) 2026-11-01 07:26:10 +00:00
Ludovic Marie 58ffd7ed33 Merge remote-tracking branch 'origin/main' into g/5595
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 08:23:44 +01:00
Ludovic Marie 07f5e6164d Merge pull request #5781 from horizon-rust/fix/anticheat-prediction-one-flag-per-slip
🤖 Agent: #5725. One prediction flag per slip across instead of 10-20 (87% of live lines continue an earlier slip). Strict cheat tests unchanged; new tests for a missed push (counts once) and a kept-up 0.05 speed lie (35 of 40 ticks count). pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:14:18 +01:00
lukehinojosaandClaude Opus 5.5 cd12172a8e Native mods: lead drops through native_loot_drops
Leads dropped through break_leash without telling native_loot_drops,
so a mod couldn't see or change the lead a player gets back, nor the
one a leashed mob drops when a player kills it.

Untying a lead, cutting it with shears (on the mob or its fence
knot) or re-leashing a mob from another holder now passes it through
the hook as a use (break_leash_used: the leashed mob, the acting player).
A mob a player kills while leashed records its killer (lead_killers);
the corpse drops its lead on its next tick as vanilla does
(Leashable.tickLeash), as a call of its own naming that killer. A lead
that snaps or loses its holder names no player and drops as before.

The API number comes from the lead_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:12:10 -05:00
Ludovic MarieandClaude Opus 5.5 82888ca49d Prediction: a slip across counts once, not on every tick its velocity lasts
87% of the official server's prediction flags (6,002 of 6,900 lines on
2026-11-01) continue a divergence that started on an earlier tick: once the
replay's velocity is off (a hit it missed), the offset dies out by the air drag
(0.91) or ground friction each tick, and each of those ticks flagged again.

The first tick off after ten that agreed now has its follow-up tick try the
velocity the client was left with (across only, at most 0.5), as another
candidate. A single slip then counts once; a blink leaves the velocity alone; a
tick that agrees only thanks to it never becomes the setback point. Not up and
down: the vertical drag (0.98) would let a slow fly pass for a slip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:40 +01:00
TeiwazikandClaude Opus 5.5 6bb88d2149 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 08:01:18 +01:00
Teiwazik 1720b747dd Merge remote-tracking branch 'origin/main' into shader/textures
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-client-render/src/gfx/native_sky.rs
#	crates/horizon-shader-api/src/check.rs
#	docs/api/shaders.md
#	launcher/Cargo.lock
2026-11-01 08:01:09 +01:00
TeiwazikandClaude Opus 5.5 d3e42f3761 Merge main: reference and formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 07:59:11 +01:00
Teiwazik 2c1aa246ac Merge remote-tracking branch 'origin/main' into shader/weather
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	crates/horizon-shader-api/src/stages.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 07:59:02 +01:00
Teiwazik 2b8e602c5b Merge remote-tracking branch 'origin/main' into shader/sky-flags
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_pack.rs
#	docs/api/shaders.md
2026-11-01 07:55:45 +01:00
Ludovic Marie 7a8a97dd5f Merge pull request #5776 from horizon-rust/fix/server-thread-limit-crash
🤖 Agent: #5775. Official server crash 05:15Z (spawn worker WouldBlock, no save). Worker spawn failure non-fatal; connection threads capped at max_players+64. New flood test fails without the cap, passes with it; pr_ready --quick green apart from one ENOSPC test failure that passes alone.
2026-11-01 07:55:38 +01:00
rdbtCVSandClaude Opus 5.5 c9614815f4 Renderer: finish and submit frames on a submit thread; the hand in the world pass
wgpu encodes a frame's commands in CommandEncoder::finish (~85 µs) and
submits them in ~25 µs, which held the window's thread at uncapped frame
rates. The frame now goes to a "Render submit" thread that finishes,
submits and presents it while the main thread builds the next one. Every
use of the queue waits for that frame first, so writes and submissions
keep their order. Frames with a screenshot readback, error scopes, VSync,
a reconfigure or the first frame's log stay in place. Culling's readback
starts on the submit thread right after the submission.
HORIZON_SUBMIT_THREAD=0 turns the thread off.

The first-person hand now draws at the end of the world pass, after a
depth reset, when nothing runs between them: one render pass fewer.

Closes #5777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:55:23 +00:00
abit-foggyandCodebuff 80a8c274d1 terrain: sample distant tiles at an explicit mip so anisotropic filtering stops streaking
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-31 20:55:02 -10:00
Ludovic MarieandClaude Opus 5.5 76ae6b7763 Server: a refused thread no longer crashes it; connection threads are capped
A dedicated server crashed on the official server (2026-11-01 05:15Z) when the
system refused a new thread: Streaming::new's .expect("spawn worker") panicked
and the process died without saving. Worker spawn failures are now logged and
the pool runs with the workers that started.

The server starts a thread per accepted socket (vanilla's Netty uses a few for
all): past max_players + 64 live connection threads, a new socket is closed at
once, so a flood of silent sockets can't use up a container's thread limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:54:08 +01:00
Teiwazik 669cc4d8fa Merge remote-tracking branch 'origin/main' into shader/pack-pages
# Conflicts:
#	docs/api/shaders.md
2026-11-01 07:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 6d035f2fa1 Chunk sending: encode a section from its stored palette ids
A section's first encoding unpacked its 4,096 block states, then built a
palette from them again (Paletted::from_values). For a list palette the
stored ids are now renumbered in first-seen order instead: the same palette,
indices and bytes, without the values. Global palettes keep the old path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:52:40 +01:00
Ludovic Marie 04029578c5 Merge pull request #5774 from horizon-rust/perf/mob-tick-no-copy
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_creature_entities 4.95→4.32 ms (−13 %), MSPT 26.59→25.78 (−3 %). pr_ready --quick pass (1655 horizon-client tests, 511 Python). Tick order and RNG unchanged; 34 harnesses in CI.
2026-11-01 07:48:35 +01:00
Teiwazik c958240f31 Merge remote-tracking branch 'origin/main' into shader/s9-materials 2026-11-01 07:45:57 +01:00
Teiwazik cf281cc812 Merge remote-tracking branch 'origin/main' into shader/s8b-player-shadow 2026-11-01 07:38:39 +01:00
masa-masa-0314andGLM 5.3 320b94aaea Horizon strings: the shader options and split profiles speak every bundled language
The shader options page and the split-profile prompt added six keys to
`en_us` only; zh_cn, ru_ru, es_es, pl_pl, uk_ua and tr_tr showed the
English text through the fallback, and a language's own words were
missing where the page is most visible. All six bundles carry them now.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-11-01 14:32:00 +08:00
Arthur Black bca8c9a0b8 Native mods: read light, weather, time of day, world height and stack sizes 2026-10-31 23:28:28 -07:00
Arthur Black 14dcadde70 Native mods: read the redstone signal reaching a block (native_neighbor_signal) 2026-10-31 23:28:28 -07:00
Ludovic Marie 26747867df Merge pull request #5714 from TiggerTakkyTTT/fix/native-mod-list
🤖 Agent: Fixes #5690 (no Build button with only GitHub mods); verify + launcher green; approved as code owner. Queued.
2026-11-01 07:17:47 +01:00
Aaron Wang e3fcbf1b3c Keep unbreakable when syncing an inventory stack
The integrated server retained minecraft:unbreakable, but network_stack
never emitted its unit component. The client therefore still drew a bar
even with the rendering guard in the previous commit. Send UNBREAKABLE
as Component::Unit; the client decoder already preserves unit components.

Add a regression test through stack_to_network, Slot write/read and
from_network, checking both ordinary and unbreakable damaged hoes.
It fails without the encoder entry and passes with it. Verify the actual
server-thread client retains the component and loses only the unbreakable
hoe's bar in the hotbar screenshot.

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:16:43 -05:00
Ludovic MarieandClaude Opus 5.5 ab376df4e0 Mob ticks: no copy of every mob each tick; a fast hasher for the per-mob sets
tick_creature_entities takes the mob list, ticks it, and appended it back
into the emptied list: a new buffer and a copy of every Mob each tick (and
the old buffer freed). With no mob added meanwhile the list now goes back as
it is. The dead-mob set and the guest bindings, looked up per mob each tick
and never iterated, use FxHash instead of SipHash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:13:44 +01:00
Ludovic Marie 87e5ca82de Merge pull request #5767 from horizon-rust/fix/anticheat-prediction-item-use
🤖 Agent: #5725 step 2. Guest item use (eating, bow, shield) now slows the replay as it slows the client. Vanilla 26.3 client walking/sprinting while drawing a bow 3 min: 503 prediction flags → 2 (0.045). pr_ready --quick all green on 3bc251ea5.
2026-11-01 07:11:40 +01:00
Stink-OandClaude Opus 5.5 f641477ff6 feat(mods): native mods can open custom menus
`native_mod_screen_open(mod_id)` opens a full-screen menu of a client mod's own over the world (`Screen::Mod`, its
`NativeScreenView.id` `mod`), `native_mod_screen_close` closes it, `native_mod_screen()` says whose is open, and Escape
closes it as any screen. It draws nothing itself: the screen hooks fire over it as over any other, so the mod draws it
in `native_screen_render_post` and takes its clicks, scroll, keys and typing from the mouse, scroll, key and
character hooks (#5543). Two drawing helpers beside `native_fill` for that: `native_quad` (a wedge or a slanted bar)
and `native_item_model` (an icon drawn with another `item_model`).

Ported from Stink-O/horizon ae531730c (`ui.show`) and 848a0686c, reshaped onto the screen hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:10:40 +00:00
Ludovic Marie c624e9c1d8 Merge pull request #5765 from horizon-rust/feat/server-low-memory
🤖 Agent: [engine] low_memory (off by default) sets MIMALLOC_ALLOW_THP=0 for the game. Measured playhorizon 20 bots: idle RSS −60 MB, −190 MB at 20 players, MSPT +1.2 % (5 rounds). pr_ready --quick pass (98 horizon-server tests incl. the new env test, 511 Python); server reference regenerated.
2026-11-01 07:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 16559f6668 Anticheat prediction: play a guest's item use as its client does
An item in use (food, a bow, a shield) slows the client to its
use_effects speed (0.2 for most) and stops its sprint. The replay plays a
guest on the server's copy of its body, which takes the item use from
Game.sync_player_body, and that runs only for the window's own player: a
guest's copy never had one. So the replay walked a guest who was eating
or drawing a bow at full speed. That put it 0.8 of a walk (0.173 blocks a
tick) or of a sprint (0.224) off, every tick of it. 0.173 was the official
server's most common prediction flag.

guest_replay now sets the copy's item use speed and its no-sprint flag
from the item the guest is using, as it already did for the glider. A
vanilla 26.3 client sprinting and walking while drawing a bow for 3
minutes: 503 flags before, 2 after (0.045 blocks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:02:51 +01:00
Ludovic MarieandClaude Opus 5.5 8e692458eb horizon-server: [engine] low_memory turns transparent huge pages off
mimalloc backs the game's memory with transparent huge pages where Linux
allows them; many are only partly used. low_memory = true starts the game
with MIMALLOC_ALLOW_THP=0: 60 MB less idle and 190 MB less at 20 players,
for ~1.2 % more tick time (playhorizon, 20 bots, 5 rounds). Off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:01:01 +01:00
lukehinojosaandClaude Opus 5.5 aa805243b6 Native mods: player-use drops through native_loot_drops
native_loot_drops heard block breaks and mob deaths only, so a mod
couldn't tell, or change, what a player's shears, brush or hit
dropped, nor who caused it. An auto-pickup or a global loot modifier
needs those too.

The player-use drops now pass through it, with the acting player
named and the new NativeLootDrops::used set: shears on a sheep, a
mooshroom, a snow golem or a bogged; a brush on an armadillo or a
suspicious block; shears carving a pumpkin; picking sweet berries or
cave vines; and a player's hit taking an item frame or its item. One
helper, native_use_drops, builds the context. The berry and cave vine
drops are popped inside the block behaviour, so their run of spill
spawns is tagged and retold when the spill flushes (retell_runs),
keeping each stack's spot and the random draws when no mod changes it.

The API number comes from the use_drops fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:49:44 -04:00
finalpantasyandOpenAI Codex 5515c06762 Fix launcher validation of bundled sibling crate dependencies
Validate direct same-mod staged crate paths before excluding them from
registry requirements. Preserve lock checks and propagate discovery errors.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 01:41:48 -04:00
Aaron Wang 8860e928fc No durability bar on an unbreakable item
ItemStack.isBarVisible is isDamaged, which needs isDamageableItem: a
max_damage and a damage, and no minecraft:unbreakable. draw_item_decorations
only looked at the item's max_damage and the stack's damage, so a damaged
unbreakable stack (/give @s diamond_hoe[damage=58,unbreakable={}]) showed a
green bar. It now asks stations::is_damageable, the port of
isDamageableItem the anvil and the grindstone use.

Fixes #5721

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:39:49 -04:00
reservedbytes d81a16f409 feat(mods): content mods' thrown entity types are saved, summoned and listed 2026-11-01 04:33:54 -01:00
Teiwazik cb1f775f0c Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage 2026-11-01 06:33:00 +01:00
Ludovic Marie 3bc251ea5a Merge pull request #5751 from horizon-rust/perf/light-views-on-workers
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: streaming 4.53→3.46 ms (−24 %), MSPT median 26.59→26.37. pr_ready --quick pass (831 render tests, 511 Python); forced_chunks/generation_saves/streaming_meshes pass. Light output unchanged; 117 harnesses in CI.
2026-11-01 06:32:03 +01:00
TeiwazikandClaude Opus 5.5 450feba95d Shader API: [[lights]], the colour of block light by the block giving it off
Block light has a brightness but no colour, so a pack can't make a soul torch blue. Section meshes now
list their light sources, and a 3D grid around the camera holds the colour reaching each cell, read
through block_light_color. Block light keeps its own brightness, which walls stop, so the grid only
tints it and needs no occlusion of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 06:24:52 +01:00
Ludovic Marie 9cf948a102 Merge pull request #5750 from horizon-rust/fix/main-build-throwable-no-gravity
Thrown projectiles keep NoGravity: save_roundtrip passes again
2026-11-01 06:23:19 +01:00
Ludovic MarieandClaude Opus 5.5 2c935d8d91 Thrown projectiles keep NoGravity: save_roundtrip passes again
Since #5641 a saved snowball, egg or ender pearl loads as a live projectile,
but NoGravity was dropped: the scene's floating snowball and pearl fell into
the floor and were gone before the save. Entity.getGravity is 0 with
NoGravity; the projectile now reads it, honours it and writes it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:22:38 +01:00
lukehinojosaandClaude Opus 5.5 e2b0df56bd Native mods: read experience orbs, give experience and levels
A mod could cancel an experience orb in native_entity_join_level but
not read its value first, nor give experience to anyone. An
auto-pickup or a machine that pays out experience needs both.

native_experience_orbs(dimension, min, max) reads the orbs in a box,
as native_item_entities does for items. native_give_experience(uuid,
points) gives points as an orb a player touches does: Mending repairs
first (repairPlayerItems), then giveExperiencePoints with the level-up
sound. It reaches any player from any server hook, through as_guest
for a guest, and refuses one swapped out by another player's hook.
native_give_levels(uuid, levels) adds levels or, negative, takes them
off (giveExperienceLevels), as the anvil and enchanting table charge.
Both go through one dispatch, native_as_player.

The API number comes from the give_experience fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:14:57 -04:00
some-du6eandClaude Opus 5.5 d728e15b38 OptiFine CIT: zero book generations and flights read as 0; Latin-1 rule files load
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:12:14 -06:00
some-du6eandClaude Opus 5.5 1a50677e6b OptiFine CIT: match stacks against the rules, indexed by item and cached per stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 a9f96fe589 OptiFine CIT: load the enabled packs' rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:11:23 -06:00
some-du6eandClaude Opus 5.5 86a346540e OptiFine CIT: bucket, painting and 1.20.2 potion tags; anchored regexes; escaped emoji; linear key reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 -06:00
some-du6eandClaude Opus 5.5 e0e002d5c2 OptiFine CTM picks: compile fixes (model Direction, disjoint borrows)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:06:31 -06:00
some-du6e 4c8d6b3595 Merge optifine/emf-geometry into optifine/emf-animations 2026-10-31 23:01:46 -06:00
some-du6eandClaude Opus 5.5 64d0b1a4e5 OptiFine CEM: refuse oversized pack files before reading them, log what a model left out
Follows the .jem reader's review changes: files over the model's byte
budget are refused by size, and the reader's warnings reach the log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:01:03 -06:00
some-du6e d3980fec3a Merge optifine/emf-expr into optifine/emf-geometry 2026-10-31 22:59:54 -06:00
some-du6eandClaude Opus 5.5 5f9545fb9c OptiFine CTM picks: the test's rule order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:57:30 -06:00
Ludovic Marie cbfae59314 Merge pull request #5702 from rdbtCVS/perf/frame-cpu
Cull off-screen dropped items; fix index crash after translucent sections
2026-11-01 05:57:28 +01:00
some-du6e b88a1aad19 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:57:10 -06:00
some-du6e ddfb64045f Merge the third review round into optifine/emf-expr 2026-10-31 22:57:10 -06:00
Ludovic Marie 3dba8448f6 Merge pull request #5726 from horizon-rust/feat/anticheat-prediction-detail
🤖 Agent: log-only detail for prediction lines (#5725 step 1); unit tests for the worst tick's offset/state; vanilla client on a dedicated server logs the new detail; pr_ready --quick all green on 21a12582c.
2026-11-01 05:57:01 +01:00
some-du6eandClaude Opus 5.5 c1e854dfcb OptiFine CTM picks: review fixes
A rule naming blocks and tiles needs both; block rules are indexed by
block, so a state tests only its own; textures/ tiles reuse their sprite;
overlay and custom-name rules load no tiles yet; top passes the top and
bottom faces on; tests for connect=tile, repeat, h+v, skips, defaults and
insert_file. Also fixes a double borrow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:56:03 -06:00
Ludovic Marie d1e40035ef Merge pull request #5476 from Teiwazik/shader/s7-shadow-translucent
🤖 Agent: requeued after the LAND-STOP: holds #5461's main merge; tests + clippy pass.
2026-11-01 05:55:50 +01:00
Ludovic Marie 6ae81c6449 Merge pull request #5461 from Teiwazik/shader/s6-shadow-solid
🤖 Agent: requeued after the LAND-STOP: main merged (Frame.mods first, shadow fields after it, reference regenerated); shader-api + render native tests + clippy pass.
2026-11-01 05:55:46 +01:00
some-du6eandClaude Opus 5.5 d454a1fcbd OptiFine CEM: the work budget charges what is walked and copied
- Strings read or copied out of the files (part names, ids, animation
  targets and expressions, axes, numbers written as text) cost one per
  started 64 bytes, each time; animations pay theirs before the copy.
- A submodel costs its keys, a part the layers its keys go through.
- by_id no longer copies an id it already has.
- Tests: shared .jpm with a 1 MB expression and with 200k submodel keys,
  the .jem's bytes in the byte budget, non-object animation items, junk
  boxes that only exhaust when keys are charged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:22 -06:00
some-du6eandClaude Opus 5.5 2c4c4f83d0 Merge main: the coarse backdrop in main's wanted queue
Main rebuilds a heap of wanted tiles when the view moves and selects
incrementally. The backdrop becomes a class in `Wanted`'s order (seam,
backdrop, the rest), and `evaluate` keeps split tiles at the seam from
standing in, as `Plan::select` does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:30 -06:00
Ludovic Marie 46a177c046 Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-01 05:53:46 +01:00
Ludovic Marie 205d4c32c7 Merge main into #5461 (after the native shader params, #5411)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 05:52:57 +01:00
some-du6eandClaude Opus 5.5 5d7f965b72 OptiFine random entities: mobs wear their pack's texture variants
The entity atlas packs the variants horizon_optifine::random_textures finds; each mob
chooses its variants when this window first sees it (biome, height, blocks, moon,
time and weather fixed then) and again once a second (name, profession, colour, baby,
health, size); render_visible draws the chosen variant's sprite for the body and
each layer. Layers without rules follow the body's variant (sheep wool), villager
layers choose their own. A pack reload makes every mob choose again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:37 -06:00
some-du6eandClaude Opus 5.5 35f2791025 OptiFine random entities: map, not and_then, for the always-found rules id
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:52:05 -06:00
Ludovic Marie 8a4ea81699 Merge pull request #5413 from levzzz5154/launcher/gpui-fast
🤖 Agent: launcher on GPUI Fast (Apache-2.0; fixes the NVIDIA/blade crash #3812); macOS text fixed (font-kit) and checked against main by screenshot; launcher tests + clippy pass on macOS; approved as code owner.
2026-11-01 05:51:27 +01:00
some-du6eandClaude Opus 5.5 719c862c93 OptiFine CIT: block entity and head tags that moved drop the rule; display keys stay in custom data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:50:50 -06:00
some-du6eandClaude Opus 5.5 a0144420a9 OptiFine CEM: third review round of the expression compiler
- pow follows Java's Math.pow: a NaN exponent is NaN, and 1 or -1 to an
  infinite exponent is NaN; x to 0 stays 1, NaN included
- Test clamp with a NaN upper bound
- The too-long test checks the error lands inside the expression instead
  of pinning an offset that depends on when nodes are counted
- Boundary test: a 512-term sum (1023 nodes) compiles and runs, 513 terms
  are too long

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:49:34 -06:00
Ludovic MarieandClaude Opus 5.5 ef2b5d25fa Launcher: GPUI Fast's macOS text system needs font-kit
Without the feature gpui-fast-platform has no font source on macOS: the
launcher drew no text at all and half its home page was blank. With it,
the home page matches main's GPUI build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:48:28 +01:00
some-du6eandClaude Opus 5.5 e713f1401e OptiFine CTM: tiles in the block sprites, and each face's rule picks one
Connected-texture tiles load as sprite layers (Sprites::insert_file reads
a texture outside textures/), and ConnectedTextures keeps each block
state's and sprite's rule list, built once at load. pick() walks them in
rule order for a face: ctm, ctm_compact (quarters and ctm.N), horizontal,
vertical, h+v, v+h, top, random (weights, loops, symmetry, linked),
repeat and fixed, with faces, heights, biomes, connect=block/state/tile,
innerSeams and the axis of logs. Without CTM files nothing is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:48:11 -06:00
Ludovic MarieandClaude Opus 5.5 eb6d6c3616 Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:41 +01:00
Ludovic MarieandClaude Opus 5.5 6fa9bef54f Anticheat prediction: log what the worst tick did
A prediction line said only how far off a move was. On the official server
it logs about 15,000 lines an hour, from ordinary players (mostly 0.1 to
0.2 blocks) and cheaters (1 to 10 blocks) alike, and nothing in a line
tells a glide, water, a ladder or a lag from a cheat, so the check can't
be trusted to act.

Each line now ends with its worst tick as the replay played it: where the
client said it went less where the replay ended (x, y, z), how far the
client said it moved, and what the replayed body was doing (ground or air,
water, gliding, sprinting, sneaking, swimming, flying, riding). Only
flagged ticks pay for the text; the state is a few bits per played tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:06 +01:00
some-du6eandClaude Opus 5.5 1c63577e62 OptiFine random entities: an oversized or unreadable rules file keeps the default
Review round 3: such a file now holds no rules (every mob wears the texture itself)
instead of falling back to an even choice; the cap is 64 KiB, and a test pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:46:20 -06:00
some-du6e 344faf4ee9 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:43:49 -06:00
some-du6eandClaude Opus 5.5 8bdc00f39d OptiFine random entities: no leading-zero variants, and a size cap on rules files
Review round 2 fixes: creeper02.png is no variant 2; a .properties over 256 KiB is not
read; files() says it lists every variant; tests pin Latin-1 in a value that decides,
and the texture's own folder coming last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:43:04 -06:00
Ludovic MarieandClaude Opus 5.5 f8ee6221ff Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:46 +01:00
some-du6eandClaude Opus 5.5 51f50aaeba OptiFine CTM geometry: clippy's identity_op in a test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:39 -06:00
Ludovic Marie 81b0cd084e Merge pull request #5484 from guilhermejansen/fix/macos-updater-keychain
🤖 Agent: macOS updater keeps retired bundles until unused (fixes #5427 Keychain 100002); main merged; launcher 433 tests incl. 7 mac_update + clippy pass on macOS; approved as code owner.
2026-11-01 05:42:36 +01:00
some-du6eandClaude Opus 5.5 d760738fb1 OptiFine CIT: tags that became components, Resewn's key names, and keys with a bare colon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:34 -06:00
some-du6eandClaude Opus 5.5 3a16789202 OptiFine CEM: fourth review round of the .jem reader
- An entry the budget cuts down to no quads is left out, not drawn empty
- Animations are all or nothing per entry, checked and charged before baking
- A box costs max(faces, keys, 1); non-object items of boxes, submodels and
  animations cost 1, and so do non-scalar animation values
- MAX_BYTES (now 4 MiB, pub) bounds the .jem and all its .jpm files together;
  a cached .jpm counts once
- Boxes whose corners or UVs overflow to non-finite numbers are skipped
- baseId dedup compares only against the entry's own .jpm layer
- Tests: a real lone-surrogate escape case, valid oversized JSON for the byte
  limit, the shared byte budget, repeated keys, non-finite boxes, 1 unit left

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:42:31 -06:00
some-du6eandClaude Opus 5.5 cfdc34710f OptiFine random entities: one walk for every folder, and rules that stay in their folder
Review round 1 fixes: the texture's own folder is walked like the others (no more
probing, no gap cut where rules name a variant, inherited rules there too); inherited
rules only cut the file name, never a folder; variant 0 and names ending in _ are no
variants; Latin-1 .properties keep their rules; files() hands the atlas each variant's
path and name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 d4b6fa4862 OptiFine random entities: find each texture's variants and rules in the packs
horizon_optifine::random_textures scans etf/random, optifine/random, optifine/mob,
mcpatcher/mob and (with a .properties beside it) textures/entity for numbered
variants and their rules, keyed by the entity atlas's texture names; textures
without their own rules use the name they extend (wolf_tame -> wolf).
paths::files_under walks a folder for files of given endings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:41:48 -06:00
some-du6eandClaude Opus 5.5 db3b3dfb96 OptiFine random entities: a file holds at most 1024 rules
Each rule reads every entry, so a file of 200,000 rules took minutes; past the first
1024 rule numbers they are dropped (found reviewing the next PR, which reads them on
every pack load).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:40:53 -06:00
some-du6e 5ac3d9b85c Merge branch 'optifine/ctm-rules' into optifine/ctm-connect 2026-10-31 22:40:31 -06:00
Ludovic Marie 37fd2a4722 Merge pull request #5458 from xFedeT/feat/debug-world
🤖 Agent: DebugLevelSource grid + isDebug gates (rdbtCVS approved); main merged (random-tick masks, superflat structures); clippy + debugworld/save tests pass.
2026-11-01 05:39:54 +01:00
some-du6eandClaude Opus 5.5 337e02a71e OptiFine CTM rules: the tile cap holds before every tile
Names were pushed before the final count check, so a 10 MB list built
hundreds of MB of paths first. matchTiles and connectTiles stop at the cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:49 -06:00
Ludovic MarieandClaude Opus 5.5 c539244947 Merge main into #5484
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:39:40 +01:00
some-du6eandClaude Opus 5.5 883cf5b01e OptiFine CEM: second review round of the expression compiler
- clamp keeps a NaN value NaN, like Java's x < lo ? lo : Math.min(x, hi)
- round returns Java's int: NaN is 0, results saturate at i32::MIN/MAX
- calls take up to u16::MAX arguments, so long keyframe tracks compile
- tests: laziness values, an expression just under the node cap, the
  offset of the too-long error, a 300-frame keyframe, clamp/round NaN
  and infinities

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:39:20 -06:00
Ludovic Marie 7baaa4423a Merge pull request #5412 from Vaspyyy/feat/native-camera-shake
🤖 Agent: native_camera_shake: identity without a mod, scaled by Distortion Effects, bounded; holds #5411. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:37:55 +01:00
Ludovic Marie acc1fdceb5 Merge main into #5458
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/world_loading.rs
2026-11-01 05:37:29 +01:00
Ludovic Marie 31fccc920b Merge pull request #5409 from Vaspyyy/feat/native-falling-blocks
🤖 Agent: native_spawn_falling_block: server-only, bounded speed, 1024 cap, loaded chunk; holds #5406. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:36:35 +01:00
some-du6eandClaude Opus 5.5 9bffd28a6a OptiFine CTM geometry: test symmetry, covered corners and every turned face
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:35:08 -06:00
ErikKalikas a5937ef682 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw-item-helpers
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 06:34:10 +02:00
Ludovic MarieandClaude Opus 5.5 68c5daa3ae Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 e554c66ae2 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic Marie 3099586bbc Merge pull request #5447 from Qwerner88880/hooks-ingredients
🤖 Agent: NeoForge ingredient shapes (compound = union, intersection, difference); rdbtCVS's points fixed; HashSet-order test made order-free; holds #5439.
2026-11-01 05:32:46 +01:00
Ludovic Marie a1a4fd46be Merge pull request #5439 from Qwerner88880/hooks-conditions
🤖 Agent: neoforge:conditions gate on every recipe loader; rdbtCVS approved, arismoko checked in game; main merged; conditions tests + clippy pass.
2026-11-01 05:32:43 +01:00
some-du6eandClaude Opus 5.5 32ee28a84c OptiFine CTM geometry: no fixed point at the origin, picks stay in range
The random hash steps by the golden ratio so the origin's down face isn't
stuck at mix's zero; weights that don't fit the tile count pick every tile
alike; dot products widen to i64; tests pin hand-computed values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:32:09 -06:00
Stink-OandClaude Opus 5.5 8f257f4672 feat(mods): native mods can push the player and set their flying speed
`native_push_player([x, y, z])` on a client, added to the local player's velocity as an explosion's knockback is (the window's player moves itself, as vanilla's client does), and `native_set_flying_speed(player, speed)` for server mods, a player's creative flying speed kept as vanilla keeps its abilities (a respawn or a game mode change keeps it). A test for the push. Ported from Stink-O/horizon 63ea5c582 and 848a0686c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:32:08 +00:00
some-du6e 25023c28c9 Merge optifine/emf-jem into optifine/emf-expr
# Conflicts:
#	crates/horizon-optifine/src/cem/mod.rs
2026-10-31 22:31:42 -06:00
Ludovic MarieandClaude Opus 5.5 b7ce7c00da crafting test: a tag's items have no fixed order
The test tag is a HashSet, so the compound's union came out in either order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:31:41 +01:00
Stink-OandClaude Opus 5.5 b5c8018c47 feat(mods): native mods can raycast across the distant terrain and own an item's use
`native_raycast(distance)` and `native_view_hit()` (the first block along the view, up to 1024 blocks, across the distant terrain past the loaded chunks), `native_held_item()` (the main hand's stack, its custom name and data as SNBT), and `native_claim_items(mod_id, ids)`: a right-click with one of those items is the mod's alone (the server never hears of it), and `native_poll(mod_id)` gives `UseStart` and `UseStop` with the ticks it was held, for a held-to-charge item. Tests for the claims and the event queue. Ported from Stink-O/horizon ae531730c and 896853699.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:39 +00:00
Stink-OandClaude Opus 5.5 9dc99a5147 feat(mods): native mods can read the world's clock
`native_get_clock()` gives a client mod the world's game time and time of day in ticks (none on the title screen), as `Level.getGameTime` and `getDayTime`: for anything timed to the day, the sun or the world's own clock. Ported from Stink-O/horizon's `game.get-clock`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:32 +00:00
some-du6eandClaude Opus 5.5 763a448985 OptiFine random entities: say what empty Facts.blocks means
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:11 -06:00
Ludovic MarieandClaude Opus 5.5 ae0ed11caf Streaming: build the light views of proto chunks on the workers
A light job for a column of the last ring reads its neighbours past the
area as views of their decorated proto chunks. The server thread built
those views itself while it scheduled light (~8 columns per tick at 20
players). The proto chunk is now checked out to a worker, like a save,
which builds the view; the light job is scheduled once it is back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:29:03 +01:00
some-du6eandClaude Opus 5.5 90f5650007 OptiFine random entities: a blocks rule fails when no blocks are known
Round 6 review: with no block facts, '!stone' no longer holds (as a biome rule with no
biome already fails).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:41 -06:00
some-du6eandClaude Opus 5.5 c0be27ae15 OptiFine CEM: first review round of the expression compiler
- keyframe: no usize overflow for infinite, huge or NaN k; past the end is the last frame
- parser caps nesting at 64 and nodes at 1024 ("expression too deep/long" with an offset),
  so no pack can overflow the stack while parsing, folding or dropping
- programs deeper than the 64-value stack (long keyframe tracks) run on a per-thread buffer
- ifb returns 1/0; nbt(key, exists:false) compiles (raw test skipped, value 0)
- catch falls back on NaN only; min/max return NaN like Java's; round is Java's, exact
- unseeded random() starts each thread somewhere else; unary + parses
- !, && and || lower to comparisons and ifs (Not/And/Or/Bool gone); easings derived from
  ease-in and moved with the splines to cem/curves.rs; Expr::slots removed
- bench prints CPU time; Error documented; tests for each of the above

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:40 -06:00
some-du6eandClaude Opus 5.5 df3306556b OptiFine CTM: the tile each method picks from a face's neighbours
horizon_optifine::ctm::connect: face texture directions (vanilla cube and
the axis-turned cube column), the 8-neighbour mask with innerSeams, and
the tile tables of OptiFine's templates: ctm's 47, ctm_compact's
quarters, horizontal, vertical, h+v, v+h and overlay's 17, plus repeat's
pattern place and a fixed, weighted random pick per position and face.
Shared with other connected-texture formats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:39 -06:00
some-du6eandClaude Opus 5.5 e7c5e9d1ab OptiFine CIT: a missing named asset drops the rule; Resewn's hand spellings; ~ opens a segment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:26:11 -06:00
Ludovic Marie 05f240102a Merge pull request #5480 from Vaspyyy/fix/native-summon-orbs-passengers
🤖 Agent: native_summon root ids: orbs (canceled → None), end crystals and cushions; passengers placed by /summon's place helper; clippy clean.
2026-11-01 05:24:58 +01:00
hasanbkaylan 65c21a8463 Update tr.json 2026-11-01 07:24:29 +03:00
some-du6eandClaude Opus 5.5 a52700ebae OptiFine CEM: third review round of the .jem reader
- Past the node budget, later parts are left out instead of replacing their vanilla part
  with an empty bone.
- Budget: every box costs at least one, the last box keeps the faces that fit, and the
  "left out" warning fires only when something was cut (Bake { left, cut, unread }).
- Animation pairs count against the same budget.
- Layers carry a key index and a base's layers already present are not added twice.
- Per-part textures and sprites are noted in submodels too.
- JSON: a high surrogate before a non-low escape gives U+FFFD and the escape is read on its
  own; one shared `get`.
- Tests for byte limits, a missing .jpm named twice, own keys over a .jpm's (also in a
  base), the budget's edges, surrogates and comment marks in strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:24:16 -06:00
Ludovic MarieandClaude Opus 5.5 1488ba172e Merge main and #5439's main merge into #5447
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:24:08 +01:00
Ludovic Marie 88e4e2b847 Merge main into #5439
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:24:07 +01:00
some-du6eandClaude Opus 5.5 2e80781c76 OptiFine random entities: the bad-condition test uses facts a dropped entry would pass
Round 5 review: run the never-match cases on a named mob in plains, so a lone '!' kept
in biomes or names turns the test red; the crate doc names ETF's docs too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:23:47 -06:00
Ludovic Marie b6134f4841 Merge pull request #5414 from sirrayi/fix/fox-trust
🤖 Agent: merged main. Fox trust list, save/load, breeding and egg-on-fox trust (rdbtCVS's fix, approved). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:27 +01:00
Ludovic Marie ba8be81ecb Merge pull request #5450 from reservedbytes/fix/native-hurt-server-canceled-notes
🤖 Agent: merged main. Canceled hits forget their attackers on every damage path (PvP, /damage, thorns, dragon, blast, potion); test. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:23 +01:00
Ludovic Marie 9267c6870b Merge pull request #5442 from ezejde/fix/head-profile-texture-patch
🤖 Agent: merged main. ResolvableProfile texture patch drawn for heads, read from the active resource root, reloaded on pack change. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:19 +01:00
Ludovic Marie 50f47f3acd Merge pull request #5441 from xFedeT/fix/rabbit-raids-carrots
🤖 Agent: merged main. Rabbit.RaidGardenGoal + MoreCarrotTicks; customServerAiStep's nextInt(3) checked in bytecode (same order). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 5fc86a7384 native_summon: end crystals and cushions keep their root ids too
The fallback for roots no command Target stands for checked only orbs after
the canceled-orb fix; end crystals and cushions also take an id at once
(rdbtCVS's re-review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:23:07 +01:00
some-du6eandClaude Opus 5.5 bb23a1ac37 OptiFine CTM rules: an empty namespace is minecraft; docs match the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:22:40 -06:00
Ludovic MarieandClaude Opus 5.5 f5a7175f27 Merge main into #5480
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:21:40 +01:00
Ludovic Marie e91a4ba7c0 Merge #5411 (queued ahead of it) into #5412
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:51 +01:00
Ludovic MarieandClaude Opus 5.5 6fbec036d7 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:51 +01:00
Ludovic Marie b36c7f9da5 Merge #5410 (queued ahead of it) into #5411
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 aff0fec800 Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:50 +01:00
Ludovic Marie 8a50d1c414 Merge #5409 (queued ahead of it) into #5410
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

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

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 e600371fc9 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 59d8117f64 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:48 +01:00
Ludovic Marie 21a12582cf Merge pull request #5433 from k3yh/fix/nether-water-evaporate
🤖 Agent: merged main. BucketItem.emptyContents in the Nether: sendParticles(LARGE_SMOKE, corner, 8, 1,1,1, 0, ALTERNATIVE) now reaches clients; ALTERNATIVE spread added. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:34 +01:00
some-du6eandClaude Opus 5.5 733c361874 OptiFine random entities: a lone ! or an empty weights line, and one key scan
Round 4 review fixes: '!' with nothing after it skips the rule; an empty weights line
is an even choice instead of dropping the rule; one pass over the rule's keys; biome
patterns through Name::parse; the cap test through max_texture and select.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:33 -06:00
Ludovic Marie aef5704b1e Merge pull request #5418 from xFedeT/feat/spawner-minecart
🤖 Agent: merged main. MinecartSpawner: BaseSpawner ticks at the cart, saved/loaded, drops and picks as a minecart, entity event broadcast. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:30 +01:00
Ludovic Marie 8bf92ca0ae Merge pull request #5715 from horizon-rust/perf/lan-attribute-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: lan_entities 1.13→0.92 ms (−19 %), tick_lan 4.52→4.27 ms. pr_ready --quick pass (1635 horizon-client tests, 511 Python). attribute_updates harness PASS; same ids, bases and modifiers sent.
2026-11-01 05:20:26 +01:00
some-du6eandClaude Opus 5.5 a83bb47af4 OptiFine CTM rules: namespaced sprites in textures/, refused tiles fail the file
matchTiles=ns:a/b now names ns:textures/a/b, as Continuity's tiles do; a
tile outside the resource root fails the file rather than shifting the
weights after it; weights past Integer.MAX_VALUE count 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:20:04 -06:00
some-du6eandClaude Opus 5.5 a8a1d74d15 OptiFine random entities: biome patterns, and empty conditions skip the rule
Round 3 review fixes: biomes read ETF's pattern:/regex: forms (matched with or without
the namespace) and their negation; an empty condition never matches; drop a no-op
check and two fixture rules that tested nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:16:36 -06:00
some-du6eandClaude Opus 5.5 d0377992db OptiFine CEM: inheriting shares instead of copying, every entry and bone counts
Second review round of the .jem reader:
- an entry's keys are layers (own, .jpm, baseId's) over shared objects,
  so a big base or .jpm costs nothing per entry; the first entry with an
  id is the one baseId finds
- the 1024-entry cap counts every entry, bones count against the face
  budget, and a .jem or .jpm over 8 MiB is refused
- the JSON reader takes Gson's = and => for colons and ; between items,
  quoted empty keys, and \u surrogate pairs; \u needs four hex digits
- per-part textures and sprites, not drawn yet, get one warning

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:15:02 -06:00
some-du6eandClaude Opus 5.5 5eeb2cdd7e OptiFine reader: paths with a backslash are refused too
On Windows a backslash separates folders, so a pack's ..\x would leave
the resource root once joined onto it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:15:01 -06:00
some-du6eandClaude Opus 5.5 b916c9a224 OptiFine CTM rules: assets/minecraft/ tiles stay in minecraft
A tile written as a full assets/minecraft/ path in another namespace's
file kept that file's namespace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:19 -06:00
some-du6eandClaude Opus 5.5 7a2b81f86f OptiFine CTM rules: one bound on tile ranges
The range check can't underflow, so the per-token check was redundant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:14:03 -06:00
some-du6eandClaude Opus 5.5 4bef210597 OptiFine CIT: bad ids and masks drop the rule, paths keep to the pack, and Resewn's lookups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:13:51 -06:00
ErikKalikasandClaude Opus 5.5 63bc913369 Native mods: name ItemStackRenderState.submit, what a campfire draws its items with
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:12:44 +02:00
some-du6eandClaude Opus 5.5 9375dab632 OptiFine random entities: nbt list paths, seedSource and exact health percent
Round 2 review fixes: an nbt path ending in a list index no longer disables another
rule; seedSource=entity is accepted (vehicle skips the rule); health percent no
longer loses a point to f32 rounding; an unnamed entity passes '!name' lists; tests
check the even choice through select instead of private fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:11:59 -06:00
some-du6eandClaude Opus 5.5 c76b2a249a OptiFine CTM rules: close the tile cap, refuse backslashes and bad heights
The tile cap is checked before every token, so names before a range can't
underflow it; a path with a backslash is refused (Windows reads it as a
separator); a height bound that isn't a number fails the rule instead of
opening the range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:10:20 -06:00
some-du6eandClaude Opus 5.5 af00d7af2b CLAUDE.md: list horizon-optifine among the crates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:09:23 -06:00
TiggerTakkyTTTandCodex eb46a6d619 fix(launcher): unify installed native mods and expose Build
Include GitHub and local native bundles in both installed-mod views, retaining source/update actions and the existing build confirmation.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 11:09:00 +07:00
Teiwazik 6018159ff1 Merge remote-tracking branch 'origin/main' into fix/cutout-mips-keep-coverage
# Conflicts:
#	crates/horizon-client-render/src/graphics.rs
2026-11-01 05:08:57 +01:00
some-du6eandClaude Opus 5.5 9469537b14 OptiFine CEM: read .jem files the way OptiFine does, and keep packs inside the root
Review fixes for the .jem reader:
- a small JSON reader keeps keys in file order (animations read what
  earlier ones wrote) and accepts what Gson does: a BOM, comments,
  trailing commas, single quotes, bare keys
- an entry's own .jpm wins over what it inherits through baseId; each
  .jpm is read once
- .jpm paths go through paths::resolve, which refuses ones that leave
  the pack; a missing or broken .jpm, or a baseId naming nothing, leaves
  that entry out with a warning instead of the whole model
- rotations through an odd number of inverted axes turn the right way
- numbers must be finite; attach and animation values may be strings or
  numbers; a file builds at most 1024 entries and 65536 faces
- shadowSize, per-bone textures and attachments are no longer parsed
  until something draws them
- CLAUDE.md lists the crate

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:08:39 -06:00
some-du6eandClaude Opus 5.5 ffb010b543 OptiFine random entities: a rule with a bad or unsupported condition never matches
Review fixes: conditions that don't parse (or nbt/teams/... that aren't read yet) skip
the rule instead of widening it to every entity; negated name lists exclude; an
unnamed entity fails name rules; variant indices stop at 1024; ETF's bare negative
ranges (-64--30) parse; more tests for each condition's failing side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:07:58 -06:00
some-du6eandClaude Opus 5.5 943b1c7bc1 OptiFine CTM rules: bound tile ranges, reject bad names, read Latin-1
Review fixes: a tile range or repeat pattern can't ask for billions of
tiles; a name= that doesn't parse drops the rule instead of matching every
block; weights keep their positions; flags read as Boolean.parseBoolean;
files that aren't UTF-8 are read as ISO-8859-1, as Java does; the path
check is shared with paths::resolve. Tests for load, heights and the
broken values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:56 -06:00
some-du6e d8c627e75e Merge main into optifine/emf-jem 2026-10-31 22:05:18 -06:00
some-du6eandClaude Opus 5.5 68f749df47 OptiFine CIT: read the rules of optifine/cit and their component tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:05:14 -06:00
calclbandDeepSeek Harness 0eb8fc511b name the TNT igniter in the blast's death message
Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 23:03:28 -05:00
some-du6eandClaude Opus 5.5 f51a81bbed OptiFine packs: read connected-texture rules (optifine/ctm)
Parse optifine/ctm/**/*.properties into ordered rules: every method and
its tile count, matchTiles/matchBlocks (and the file-name forms), tiles
(ranges, names, paths, <skip>, <default>, Continuity's namespaced
sprites), faces, biomes, heights (with Continuity's a..b), name, connect,
connectTiles/connectBlocks, innerSeams, weights, randomLoops, symmetry,
linked, width/height, ctm.N, tintIndex/tintBlock, layer, and Continuity's
orient, prioritize and optifineOnly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:02:41 -06:00
some-du6eandClaude Opus 5.5 6049010105 OptiFine random entities: read the rules that pick an entity's texture variant
horizon_optifine::random parses optifine/random .properties rules (textures/skins,
weights, seedOffset and the biome, height, name, profession, colour, baby, health,
moon, time, weather, size and block conditions) and picks a variant from an
entity's UUID-derived seed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:02:05 -06:00
TobiasLogicandClaude Opus 5.5 34a47de0bf Shader packs: a pack's vignette=false hides the game's vignette
shaders.properties' `vignette` key (on unless `false`) says whether the game draws its vignette
over the pack's image; most packs turn it off (BSL, Complementary, Bliss, Bloop, FastPBR,
Hysteria...) because they draw their own. Horizon ignored the key, so with the Vignette option on
those packs got both. The game's vignette now draws only when the option is on and the loaded
pack doesn't turn it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:52:27 +07:00
ErikKalikasandClaude Opus 5.5 7cb008681f Native mods: native_draw_item takes an owner, as a shelf's items have
ShelfRenderer passes its block entity as the ItemOwner (at the block's
centre, turned by its facing), so a compass on it points to its target;
without one it always points the same way. The client's copy of a block
entity has no item slots: the docs say to send the stacks drawn in the
update tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:51:54 +02:00
TobiasLogicandClaude Opus 5.5 c7782f2656 Merge main into #5701 (#5371 changed translate/rewrite.rs and mod.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:50:06 +07:00
rdbtCVSandClaude Opus 5.5 139ef2f801 Cull off-screen dropped items; restore quad indices after sorted pages
dropped_item_geometry resolved and meshed every item within 64 blocks,
even behind the camera. Apply EntityRenderer.shouldRender's frustum test
(box grown by 0.5) first, as mobs already do: ~0.2 ms -> ~0.01 ms per
frame with a few dozen items around.

draw_sections left a translucent sorted-index page bound, so the cloud
draw after it indexed past its end and wgpu aborted on the first frame.
Rebind the shared quad indices when a sorted page was used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:47:09 +00:00
reservedbytes 9a47759df3 feat(mods): mod menu types with their own slot count and data slots 2026-11-01 02:44:59 -01:00
ErikKalikasandClaude Opus 5.5 6e96e6f975 Native mods: keep a mod block entity's geometry while it doesn't change
draw.keep() in native_block_entity_submit keeps what that call drew,
relative to the block as a sign's text is kept, and the block is drawn the
same again without calling the mod until its state, its block entity, its
light or the resource packs change. Idle machines then cost a copy moved to
the camera, not a call into the mod, each frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:29 +02:00
asownerytandClaude Opus 5.5 10469d5c99 Endermen riding a boat no longer teleport in a loop
Enderman.teleport(x, y, z) fails for a passenger, repeatedlyTryToTeleport
returns before drawing, and EndermanLookForPlayerGoal.tick skips its
teleports while the enderman rides something. Horizon had none of these
checks, so an enderman in a boat "teleported" every tick (sound and
portal particles) and was snapped back to its seat, which also made its
held block jitter. Fixes #4865.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:39:23 +13:00
ClickingMasterandClaude Opus 5.5 bd837ade74 Fly picked-up tridents to their collector on server clients
A server's client keeps thrown tridents apart from its other projectiles,
so TakeItemEntity never found them and the trident just vanished instead
of playing vanilla's ItemPickupParticle. Look the trident up there too,
turn it into a pickup particle and draw it on its way to the collector.

The host also sent the pickup only for tridents that gave an item, so a
creative player's trident vanished on other clients; it now always sends
it, as Player.take does in AbstractArrow.playerTouch.

lan_e2e: trident_pickup throws a trident at each player's feet and checks
that it comes back and flies to them on both clients (fails before this).

Fixes #5577

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:38:52 -07:00
JoansitohandClaude Sonnet 5.5 6ad0a9df83 Shader packs: provide the cloudHeight and isElytraFlying uniforms
Java-format packs read both names as zero today. isElytraFlying is the
player's fall-flying state; cloudHeight is the cloud_height environment
attribute (default 192.33, dimension_type visual/cloud_height) when the
dimension's cloud colour alpha is above 0, as in vanilla's LevelExtractor,
and NaN otherwise ("no clouds"). The 192.33 literal of clouds.rs becomes
a shared constant.

Part of #3172.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 04:33:46 +01:00
Ludovic Marie c51dcda011 Merge pull request #5691 from horizon-rust/fix/lunge-motion-skipped-tick
lunge_motion: name a server tick that ran without a move, retry the harness once (#5656)
2026-11-01 04:30:59 +01:00
ElyTheCatandAntigravity 4ca447371b Merge origin/main into feat/leaf-culling
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 07:00:49 +03:30
Ludovic MarieandClaude Opus 5.5 9228c44123 lunge_motion: name a server tick that ran without a move, and retry the harness once (#5656)
#5656's falling motion (-0.3015 against vanilla's -0.2277) is a tick of
gravity more, not a change in the game: main, and the exact tree CI
failed on (#5502's head merged into 76dc5e3bd), pass every time on a
quiet machine. On a loaded one the server catches up with two ticks for
one of the jump's moves (seen: "the server ran 2 ticks for move 7"), and
the server's copy of the player falls in the tick with no move. Before
the jab that changes the motion the jab sends.

LocatorBarClient's arc now records an error when that happens up to the
jab, so the failure says why, and lunge_motion joins known_flaky so the
runner gives it its one retry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:30:20 +01:00
ElyTheCatandAntigravity 4af3055742 Add configurable leaf culling (Hollow, Solid, SolidAggressive)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 06:59:23 +03:30
spiderandClaude Opus 5.5 5fb3fa4c32 Frame generation: mobs' and dropped items' own motion vectors
The camera's motion vectors see mobs and dropped items as still, so a made frame
would show them where the real one does. The game now tags each mob's and
dropped item's vertices with its entity id (Frame::entity_motion); one whose
model has the same shape as last frame gets each vertex's own motion, drawn into
a texture at the render size where the world shows it (its depth, or a
translucent item's texture). The interpolation takes that texture as
object_motion and uses it over the camera's motion vectors. Nothing runs it yet:
the Frame Generation setting comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:23:24 +01:00
Ludovic Marie 521614fc7f Merge pull request #5689 from horizon-rust/fix/launcher-github-build-hint
🤖 Agent: Message text only, in 11 lang files that all parse; the launcher CI job runs the i18n tests. Merging once it's green.
2026-11-01 04:23:20 +01:00
Ludovic MarieandClaude Opus 5.5 a50005adb4 Launcher: point the GitHub-install message at the Mods page's Build
The Build button moved from Settings > Native mods to the Mods page, but the
message after installing a mod from GitHub still sent players to the old
setting (reported on Discord, 2026-11-01). Updated in English and the ten
translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:22:22 +01:00
Solenopsisbot 022472cea8 Merge remote-tracking branch 'origin/main' into accounts/player-dir
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/options.rs
2026-11-01 14:21:02 +11:00
Ludovic Marie fbd0ca7f11 Merge pull request #5688 from horizon-rust/automation/native-hook-stamp
🤖 Agent: The stamp commit from the native hook stamp workflow (the bot can't open PRs). Merging once CI is green.
2026-11-01 04:18:28 +01:00
Ludovic MarieandClaude Opus 5.5 bc1d4d1516 Entity tracking: a mob's movement speed looked up once per tick
mob_attributes asked the attribute catalog for movement speed (by name)
and recomputed its base value up to three times per mob and tick, on
top of the registry id built with format! per attribute (Attr::id, the
previous commit). The speed instance, the baby and the sprinting
modifiers now share one lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:12:45 +01:00
Ludovic MarieandClaude Opus 5.5 75a9116efb Attributes: static registry ids, not format! per mob and tick
The type's default of an attribute (base_value, read by mob movement and
every tick's entity tracking) and the LAN attribute sync built the id
"minecraft:<name>" with format! on each call: for a server's 2000 mobs that
is several allocations per mob per tick. Attr::id returns the id as a
static string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:10:17 +01:00
Teiwazik 177e6be16c Merge remote-tracking branch 'origin/main' into shader/s9-materials
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
2026-11-01 04:07:32 +01:00
TiggerTakkyTTTandCodex 48b338836f fix(portals): transfer mobs and falling blocks through End portals
Keep entity portal state with its level so another ticking dimension cannot
clear it. Handle falling-block portals after movement and impact damage but
before placement, preserving cooldowns, saved data and vanilla's End transfer
duplication branch. Extend integrated-server command parity with direct
falling-entity probes and remove its stale in-frame runner override.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 10:03:10 +07:00
TeiwazikandClaude Opus 5.5 af1325e6a5 Shader API: [[textures]], PNGs every stage of a pack reads
Noise, lookup tables and the like had to be computed in WGSL. A pack can now ship up to eight PNGs,
which the check decodes and every stage reads as <id> and <id>_sampler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:55:37 +01:00
Stink-OandClaude Opus 5.5 4a7c664eb8 fill: skip blocks that are already the block, match the filter once per state
A fill visits up to 32768 blocks of a handful of states. It now skips
blocks that are already the block it would place before matching the
filter (place_state would change nothing there), and matches each block
state against the filter once per fill instead of once per block.
Results, counts and side effects are unchanged. Ported from
Stink-O/horizon 242b45037 (its fill part, the same code).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:55:15 +00:00
Stink-OandClaude Opus 5.5 9fdf206c4b Keep crater break sounds from silencing sounds played to the player
Positional sounds leave 16 static channels free for sounds played to the
listener. Ported from Stink-O/horizon a72b65ea3 (sound_engine.rs, now in
horizon-client-render); that commit's other half, block destroy events
only within 64 blocks, is already here (lan/containers.rs level_event).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:55:15 +00:00
Claude 88784b6910 Merge remote-tracking branch 'upstream/main' into fix/native-summon-orbs-passengers
# Conflicts:
#	crates/horizon-client/src/game/native_entities.rs
2026-11-01 02:50:55 +00:00
Claude 64aa0b4473 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:26 +00:00
Claude a62c609529 Merge remote-tracking branch 'upstream/main' into feat/native-shader-params
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:25 +00:00
Claude c6a5386e9f Merge remote-tracking branch 'upstream/main' into feat/native-local-sound
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:24 +00:00
Claude a8b7cdc12e Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-01 02:50:23 +00:00
ErikKalikasandClaude Opus 5.5 dcec52b07e Native mods: native_draw_item and native_block_state_property for drawing block entities
An item stack drawn under a block entity's pose as a campfire's, and a block
state's property (its facing) read without a world lookup. Helper-only, with a
fragment of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:40:50 +02:00
JustKarkat eb837f2edf Fix: BlockStateId is Copy, drop the pane clone
CI clippy (--all-targets) flags clone on the Copy type; local clippy ran
lib-only and missed the test profile.
2026-10-31 21:40:33 -05:00
Ludovic Marie b6ac0602d7 Merge pull request #5670 from horizon-rust/fix/sprite-split-adapter-limit
🤖 Agent: unit tests for the split choice and resplit; HORIZON_SPRITE_SPLIT=512 renders the same world as the default on the Mac mini; pr_ready --quick green. Merging after verify + parity.
2026-11-01 03:39:17 +01:00
martipopsandOpenAI Codex 9d3c50dd6c Keep the guest profile action distinct from the enable page
Give the pause action its own translated Splitscreen Settings label. Check the transient test override through the visible saved switch rather than waiting for an untouched settings file to be written.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 22:36:31 -04:00
github-actions[bot] 593e4af363 Stamp native hook API fragments 2026-11-01 02:35:29 +00:00
Ludovic Marie 929f53ad3e Merge pull request #5578 from Sylviromi/screen-mouse-button
🤖 Agent: merger STOP was a conflict with main after #5571 landed; main merged (clean). clippy -D warnings, the native hook tests (55) and rustfmt pass on it; the rest was proven in union3 with #5571.
2026-11-01 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 475af9674a Renderer: cut block sprites by the GPU's texture array limit
A GPU with 512 layers per texture array refused to start ("GPU supports 512
texture layers; 1291 required") although terrain binds 8 sprite arrays. The
split is now the smallest of SPRITE_SPLIT, HORIZON_SPRITE_SPLIT and the
adapter's max_texture_array_layers (one less on OpenGL when a full array would
be padded past the limit), computed once when the renderer is made and used
by the sprite arrays, the shader constants and the shader pack atlas. Mip
chains prepared before the adapter was known are cut again for it. Startup
only fails when the sprites need more than 8 arrays of that split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:35:06 +01:00
arismoko e8d5b826b0 Fix native chat to acting players in hooks 2026-10-31 21:31:21 -05:00
Ludovic Marie 9be37e6877 Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:30:51 +01:00
d9350b549e Splitscreen: enable joining and choose local profiles
Rebuild the profiles and saved switch increment on current main after the compositor, controller, windows and profile-page model landed. Preserve pause dialogs and keyboard navigation, keep test overrides transient, and validate profile folder names.

Co-Authored-By: OpenAI Codex <[email protected]>

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:57 -04:00
Ludovic Marie ae4e28774f Merge pull request #5569 from v3nn7/fix/mount-offhand-steering
🤖 Agent: vanilla's Pig/Strider getControllingPassenger use player.isHolding, which checks both hands; the server path (lan_vehicles → tick_mount_riding) shares this check. Main merged; its test passes. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:27 +01:00
Ludovic Marie 678c9b7073 Merge pull request #5573 from some-du6e/feat/native-modify-default-components
🤖 Agent: main merged (item-default and loot free hooks both kept), clippy needless borrow fixed. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:23 +01:00
Ludovic Marie 63e9b9207a Merge pull request #5571 from Sylviromi/screen-render-post
🤖 Agent: main merged (the hook fires, then main's split-screen HUD). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:15 +01:00
Ludovic Marie 8978c7c35e Merge pull request #5566 from Qwerner88880/hooks-enchant
🤖 Agent: main merged (both doc sections kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:11 +01:00
Ludovic Marie c7095cc9e5 Merge pull request #5535 from mariuslozo-boop/fix/horse-inventory-close
🤖 Agent: main merged (the level check before main's lan_mount_valid). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:07 +01:00
Ludovic Marie 78547c3b61 Merge pull request #5516 from sirrayi/mods/placement-state
🤖 Agent: main merged (both sides' level hooks kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:03 +01:00
Teiwazik 1781a7bd3e Merge remote-tracking branch 'origin/main' into shader/pack-pages 2026-11-01 03:26:37 +01:00
JustKarkat a23b218ae0 Test: phantom pane-bite parity for #4928
Vanilla 26.3's PhantomSweepAttackGoal.tick bites on a plain box check
(getBoundingBox().inflate(0.2).intersects(target)) with no line-of-sight
or block test, and its targeting gate (TargetingConditions' eye-ray) is
what keeps a pane-hidden player safe. Horizon ports both, so:

- a phantom pressed against the far side of a foot-high pane wall, whose
  eye-ray passes over the wall, bites the player pressed behind it, in
  Horizon exactly as in vanilla;
- behind a window-height pane wall the eye-ray is blocked and the
  phantom never takes the player as a target, also as in vanilla.

Two parity cases pin both sides of that behaviour, mirroring the live
geometry (player and phantom each pressed against their face of the
pane, 0.125 apart, bite reach 0.2). Refs #4928.
2026-10-31 21:24:17 -05:00
Ludovic MarieandClaude Opus 5.5 db0ec1edd4 content_identity: no needless borrow (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:23:26 +01:00
TeiwazikandClaude Opus 5.5 f11233f125 Shader API: solid_materials work without a material stage
A pack with [shadow] and only [[post]] can now name the ids its shadow pass reads, and the test of
solid ids checks which layers lose their cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:20:08 +01:00
Teiwazik ddadcb2203 Merge branch 'shader/s8b-player-shadow' into shader/s9-materials 2026-11-01 03:19:32 +01:00
Ludovic Marie 1734ca3982 Merge pull request #5445 from navinor/fix/armor-stand-lerp
🤖 Agent: Review points from some-du6e fixed and confirmed (seat_own_stands ordering, the cancel() test, port-ledger citations); local lan_e2e proof posted by the author's agent; CI green on 6f941a5e9. Queued for merge.
2026-11-01 03:19:11 +01:00
Ludovic Marie 8c936ae640 Merge remote-tracking branch 'origin/main' into n5569 2026-11-01 03:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 c4cbf36967 Merge main into #5573 (item-default and loot hooks both kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:18:00 +01:00
Ludovic Marie ddeecbad3b Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:17:38 +01:00
Ludovic Marie 6ac25bbc02 Merge remote-tracking branch 'origin/main' into n5571 2026-11-01 03:17:38 +01:00
Ludovic Marie d2ad79e4c1 Merge remote-tracking branch 'origin/main' into n5566 2026-11-01 03:17:38 +01:00
Ludovic Marie 072ad5d00a Merge remote-tracking branch 'origin/main' into n5535 2026-11-01 03:17:38 +01:00
Ludovic Marie 57ae3494b8 Merge remote-tracking branch 'origin/main' into n5516 2026-11-01 03:17:38 +01:00
Ludovic MarieandClaude Opus 5.5 c30ebd6aaf Merge main into #5516 (both sides' level hooks kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:17:37 +01:00
Ludovic Marie ba8b5b4146 Merge pull request #4250 from martipops/feature/launcher-auth-status
🤖 Agent: launcher account status, profile heads, refresh and targeted sign-in (Microsoft only); rdbtCVS's points fixed earlier; main merged here (lang union, both sides' account tests, a string main dropped removed). Proven: union of main 956e35469 with 19 PRs, launcher clippy + tests green (the i18n tests after the string fix), game clippy/tests green.
2026-11-01 03:16:32 +01:00
Ludovic Marie 22a3c3c55b Merge branch 'n5571' into n5578 2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 29761e3803 Merge main into #5571 (the hook, then main's split-screen HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 cd39ec5fdc Merge main into #5566 (both hook sections kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:04 +01:00
Ludovic Marie 5a8414ddad Merge pull request #5561 from some-du6e/mods/loot-table-hooks
🤖 Agent: native loot table and drop hooks. Fixed here: NativeLootDrops.player only while the hurt memory lasts (dropFromLootTable's playerKilled). Proven: union of main 956e35469 with 19 PRs had this PR's code (clippy, workspace + content-root tests green); the author's later commits are docs, a test and rustfmt, and CI verify passes on this head.
2026-11-01 03:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 9c1bd32c77 Merge main into #5535 (the level check before main's lan_mount_valid)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:14:53 +01:00
Ludovic Marie 42ffde8cd2 Merge remote-tracking branch 'origin/main' into n5573 2026-11-01 03:14:33 +01:00
TeiwazikandClaude Opus 5.5 753c565883 Shader API: the blob stays where a pack has no sun to cast with
Also names in the docs that entities the camera culls don't cast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:14:15 +01:00
Ludovic Marie af2edf2e86 Merge pull request #5558 from Qwerner88880/hooks-consume
🤖 Agent: native consume effect kinds; fixed here: an empty namespace is minecraft's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:54 +01:00
Ludovic Marie 735f4371c5 Merge pull request #5536 from Xmilon/native/block-touch-hooks
🤖 Agent: native block contact / landing hooks; fixed here: no item/projectile sweep without mod blocks (hot path), vanilla stepOn-then-inside order, getOnPosLegacy. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:50 +01:00
Ludovic Marie 213a16d88a Merge pull request #5527 from sirrayi/mods/bone-meal-on-mod-blocks
🤖 Agent: native bone meal hooks; fixed here: Some(false) keeps the meal as growCrop does, None leaves the template's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:47 +01:00
Ludovic MarieandClaude Opus 5.5 553c11fd2f Launcher lang: drop the string main's redesign no longer uses
"Microsoft account · signed in" came back with this branch's tables in the
main merge; the code lost it in 86149f7da (i18n tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:13:12 +01:00
Ludovic Marie 09d3345c9b Merge pull request #5514 from kacpherek/fix/knot-and-orbs
🤖 Agent: fence knot clicks and orb smoothing; fixed here: the orb draws its correction step (previous kept), knot hit only within entity range; main merged. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:10:58 +01:00
Ludovic Marie 0a8def17fa Merge pull request #5654 from horizon-rust/sweepF/5521
🤖 Agent: #5521 by rdbtCVS finished (main merged: serialize keeps screenshotQuality and main's shadowCache). Screenshot Quality defaults to 1x, so at 1x and 100% scale F2 saves the window's frame as before; the scaled frame draws offscreen inside OOM/validation error scopes. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green (clippy -D warnings, workspace tests).
2026-11-01 03:08:38 +01:00
Ludovic Marie 6253329644 Merge pull request #5562 from ErikKalikas/mod-block-entity-draw
🤖 Agent: native block entity renderers; main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:08:32 +01:00
Ludovic Marie f28c2b348c Merge pull request #5574 from some-du6e/optifine/properties
🤖 Agent: OptiFine .properties reader (new crate, no dependents); fixed here: no .. paths, linear glob, no link loops; skip-release. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:11 +01:00
Ludovic Marie 707340c312 Merge pull request #5570 from some-du6e/perf/faster-prepare
🤖 Agent: parallel horizon-prepare (same output digest, release check covers both lane layouts); fixed here: the extraction test on case-insensitive disks. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:08 +01:00
Ludovic Marie 98642e8b22 Merge pull request #5512 from PzmuV1517/mods/can-survive
🤖 Agent: native can_survive / may_place_on hooks; fixed here: a piston head's neighbour change asks can_survive. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:36 +01:00
Ludovic Marie f752cb0841 Merge pull request #5503 from navinor/feat/mod-advancement-triggers
🤖 Agent: native advancement criteria triggers (+ the author's on_fire fix); main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:32 +01:00
Ludovic Marie 9a4faa20d7 Merge pull request #5502 from sirrayi/mods/biome-modifiers
🤖 Agent: biome modifiers place mod features; fixed here: unread modifier types and unknown names are skipped with a warning instead of dropping every content mod. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:27 +01:00
some-du6eandClaude Opus 5.5 a6fcdfaf84 OptiFine CEM: custom entity models animate
A .jem's animations compile with its model when the packs load and run
per mob per frame after the vanilla animation: part transforms and
visibility (this, part, part names, bone ids, a:b:c paths), per-entity
var.* and varb.*, and the entity, render and frame parameters of
OptiFine's and EMF's animation docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:05:22 -06:00
Ludovic Marie f90fb824b9 Merge pull request #3825 from martipops/split/09-windows
🤖 Agent: splitscreen part 9 (on #3801, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:22 +01:00
Ludovic Marie 0fefb3744c Merge pull request #3801 from martipops/split/08-join-with-controller
🤖 Agent: splitscreen part 8 (on #3794, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:18 +01:00
Ludovic Marie 3e34340e75 Merge pull request #3794 from martipops/split/07-controllers
🤖 Agent: splitscreen part 7 (on #3777, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:14 +01:00
Ludovic Marie 84ccfa68de Merge pull request #3777 from martipops/split/06-two-players
🤖 Agent: splitscreen part 6 (HORIZON_SPLIT testing only, off by default); main merged here (main's headless loop runs before the split's event loop). Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:09 +01:00
TeiwazikandClaude Opus 5.5 000eb647aa Shader API: the [weather] stage colours rain and snow streaks
A native pack had no way to light or tint weather, so vanilla's streaks stood out against a pack's
own lighting. Horizon still places and animates the streaks; the pack only returns their colour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 03:03:24 +01:00
reservedbytes e26bd0fe58 feat(mods): native mods use items over time (duration, animation, can eat, start and finish) 2026-11-01 01:03:17 -01:00
some-du6eandClaude Opus 5.5 c2e44bda19 Native mods: the content identity hashes the changed defaults
origin/main's identity hashes mod components' defaults so peers that
disagree are told apart; it read items.json raw, so a native mod's change
slipped past it. It reads them through item_defaults::read now. The read
and hook docs mention the stack-size range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:01:14 -06:00
lukehinojosaandClaude Opus 5.5 b126de85e3 Native mods: give a whole stack to any player (native_give_stack)
Server mods could only give plain items to the acting player
(native_give_items). An auto-pickup or a machine's output needs to hand
over the stack it has, with its damage, name and enchantments, from any
server hook. native_give_stack(uuid, stack) places it as Inventory.add
does and returns what did not fit.

Game::give's slot loop moves into inventory_add, shared by both, so
Inventory.add stays one port. The API number comes from the
give_stack fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:00:38 -04:00
some-du6eandClaude Opus 5.5 9fb61eafa6 Native loot hooks: rustfmt the hookability test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:59:46 -06:00
Ludovic Marie 690a1a4512 Merge pull request #5641 from reservedbytes/fix/save-thrown-projectiles
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:59:31 +01:00
some-du6e 2413dabfae Merge remote-tracking branch 'origin/main' into mods/loot-table-hooks
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 19:58:43 -06:00
some-du6eandClaude Opus 5.5 91b523cc48 OptiFine CEM: packs' .jem models reshape mobs
Each optifine/cem (or EMF's emf/cem) .jem replaces or joins the parts of
the model layer it names, with OptiFine's part names mapped to the game's
and baby models falling back to the adult's. Built when the packs load
(and on every pack reload, on the reload worker); a mob's draw only swaps
the layer's part tree and, when the .jem names one under textures/, its
texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:58:33 -06:00
Ludovic Marie 6647659a3b Merge pull request #5542 from kacpherek/fix/remote-ride-gone-vehicle
🤖 Agent: stop riding a remote vehicle that is gone (Entity.baseTick); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:57:14 +01:00
Ludovic Marie 321af52ea4 Merge pull request #5640 from rdbtCVS/fix/armor-stand-disabled-slots-and-lead-owner
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:36 +01:00
Ludovic Marie 504f90a09a Merge pull request #5639 from rdbtCVS/fix/mob-equipment-itemstack
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:33 +01:00
Ludovic Marie b3f954f038 Merge pull request #5636 from mariuslozo-boop/fix/inventory-revert-update
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:29 +01:00
Ludovic Marie c4fd435788 Merge pull request #5635 from rdbtCVS/fix/autosave-saves-players
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:25 +01:00
Ludovic Marie 039d514f4f Merge pull request #5634 from rdbtCVS/fix/removed-entity-drops-contents
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:22 +01:00
Ludovic Marie 8df8d78559 Merge pull request #5633 from rdbtCVS/fix/menu-still-valid-on-click
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:18 +01:00
Ludovic Marie 231b549303 Merge pull request #5632 from rdbtCVS/fix/open-menu-closes-previous
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:15 +01:00
Ludovic Marie daac34040f Merge pull request #5630 from rdbtCVS/fix/close-menus-on-leave
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:11 +01:00
Ludovic Marie bd9f2f2683 Merge pull request #5628 from JustKarkat/fix/held-block-item-culling
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:07 +01:00
Ludovic Marie ab49db6a46 Merge pull request #5626 from Teiwazik/shader/pack-settings
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:02 +01:00
Ludovic Marie 2b7b1711c9 Merge pull request #5605 from Xmilon/native/image-tooltips
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). The image follows the text and the bundle component, the rule Horizon's bundle tooltip already uses; moving both under the title, as vanilla's index 1, is a separate parity fix.
2026-11-01 02:55:59 +01:00
Ludovic Marie 483a51e9f0 Merge pull request #5538 from xFedeT/fix/tadpole-brain
🤖 Agent: tadpole AbstractFish goals and TadpoleAi brain, mob_ai scenarios; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:55 +01:00
Ludovic Marie 904e161bf1 Merge pull request #5519 from elythepreciouscat/perf/leaf-cutout
🤖 Agent: fancy leaves sorted front to back on the mesh workers (no render-thread cost); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:50 +01:00
TeiwazikandClaude Opus 5.5 fbf8c46b8e Shader API: [sky] clouds, sun and stars, for a sky that draws its own
A pack's sky stage only replaced the dome: Minecraft's clouds, sun and stars still drew over it, so a
pack with its own had two of each. Each key defaults to true, so existing packs don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:54:22 +01:00
some-du6eandClaude Opus 5.5 bb3d830ed9 Native loot hooks: the drops doc lists a mob's inventories and what comes before its table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:53:38 -06:00
Ludovic Marie e0af360ef4 Merge main into #5595's branch
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 02:53:00 +01:00
some-du6e e6d8501486 Merge remote-tracking branch 'origin/main' into feat/native-modify-default-components 2026-10-31 19:49:47 -06:00
some-du6eandClaude Opus 5.5 885988f9d1 Native loot hooks: name every non-item death drop; say which tables a mob's drops come from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:23 -06:00
Ludovic MarieandClaude Opus 5.5 df8aa42d9b Merge main into #5521 (serialize: screenshotQuality and main's shadowCache)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 737633786c Merge #5571's main merge into #5578
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:33 +01:00
Ludovic MarieandClaude Opus 5.5 cfa8e57125 Merge main into #5571 (both new tests)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 c652b50920 Merge main into #5562 (module list)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 003f47c465 Merge main into #5535 (both new lan_e2e checks kept whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:57 +01:00
JoansitohandClaude Sonnet 5.5 4647ce7af7 CI: daily check for a republished Mojang version JSON (#3333)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 02:47:32 +01:00
Ludovic MarieandClaude Opus 5.5 2c9b07d6d1 Merge main into #5514 (ExperienceOrb::tick's new argument kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:12 +01:00
Ludovic MarieandClaude Opus 5.5 08b3aca04c rustfmt's module order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:04 +01:00
Ludovic MarieandClaude Opus 5.5 175635b82b Merge main into #5503 (native_criteria next to main's native_components; both doc sections)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:57 +01:00
some-du6eandClaude Opus 5.5 96b7a3332f Native mods: keep the stack size in range for every reader
The clamp moves from the item table into item_defaults::read, after the
hook, so loot and item models read the same 1 to 99 (a negative size too).
The durability tooltip no longer underflows when a mod lowers max_damage
below a saved stack's damage (getDamageValue clamps to the max).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:50 -06:00
Ludovic MarieandClaude Opus 5.5 f9839553c7 Merge main into #4250 (lang tables: both sides' strings; account tests: both)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:38 +01:00
Ludovic Marie db4acf3105 Merge branch 'ss3801' into ss3825 2026-11-01 02:43:29 +01:00
Ludovic Marie a72a10a288 Merge branch 'ss3794' into ss3801 2026-11-01 02:43:28 +01:00
Ludovic Marie d5aefe39e2 Merge branch 'ss3777' into ss3794 2026-11-01 02:43:27 +01:00
Ludovic MarieandClaude Opus 5.5 96e310baa6 Merge main into #3777 (main's headless loop before the split's event loop)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:43:19 +01:00
Aaron Wang 6f941a5e92 Merge branch 'main' into fix/armor-stand-lerp 2026-10-31 21:42:57 -04:00
Ludovic Marie 8788e9438e Merge branch 'pr/F5570new' into fix5570 2026-11-01 02:41:57 +01:00
TeiwazikandClaude Opus 5.5 622786d903 Shader packs: [[pages]], the layout of a Horizon pack's options page
A pack with dozens of options needs them in categories. The first page is the one the button opens,
"page:<id>" opens another, "" leaves a slot empty and columns = 1 gives full-width buttons. Every page
has to be reached from the first and every option has to be on a page, or the check refuses the pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 02:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 fd5f849c42 horizon-prepare test: ask the disk whether Tag.json and tag.json are one file
The skip compared Paths, which compare case-sensitively, so it never fired
and the extraction test failed on macOS's case-insensitive file system (the
later member, tag.json, wins there). It now checks whether a case-folded
path exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:40:52 +01:00
some-du6eandClaude Opus 5.5 d0013cfc31 Native loot hooks: document the hurt memory behind the killing player; scan the loot hook from its doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:30 -06:00
some-du6eandClaude Opus 5.5 c6a450499b horizon-prepare: HORIZON_PREPARE_JOBS caps each step's threads
Each copying, hashing or compiling step spreads over that many threads; the lanes run theirs side by side, so it is no total limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:53 -06:00
Aaron Wang 1c6d6935d4 lan_e2e objects: a stand that falls, so the guest follows its moves
The objects check summoned its stands on the floor, where a guest that
never moved a remote stand still matched the host. This one starts 3
blocks up and turned to 200 degrees, so where it lands and its yaw are
compared between host and guest.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:38:02 -04:00
some-du6eandClaude Opus 5.5 02a522b868 OptiFine CEM: compile animation expressions once, evaluate them without allocating
The CEM expression language from OptiFine's cem_animation.txt with EMF's
documented additions (keyframe, keyframeloop, easings, splines, wrapdeg,
degdiff, catch, randomb, global constants e and nan), compiled to a flat
stack program with constants folded; if, && and || only evaluate the
branch they take. Variables are caller-resolved slots. The example
cem_expr_bench times an animation pack's frame of 20 expressions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:36:18 -06:00
some-du6eandClaude Opus 5.5 2a3429819e Native mods: keep a changed stack size to vanilla's 1 to 99
A mod setting minecraft:max_stack_size to 0 made merchant.rs clamp(1, 0)
panic on the server thread. Items now keeps it to ExtraCodecs.intRange(1, 99),
and the docs say what else a malformed value does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:52 -06:00
some-du6eandClaude Opus 5.5 0ed5f554b3 Native loot hooks: name what the drops hook doesn't hear; check the loot hook's attributes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:39 -06:00
Claude 82dee152fb Launcher: keep counting a secret another process renewed in the file
Review fix for the secrets file (#5095): when the keychain took a
secret but another process had written a newer copy to the file
meanwhile, the newer copy rightly stayed, yet the run stopped counting
it as seen in the file. Once that run stopped asking the keychain and
another process handed the copy over, the run couldn't read it.

The clean-up now says whether the file still has the entry, and the run
keeps counting it while it does.

Test: a launcher on the file renews A during a start's hand-over of A,
the keychain refuses B; after another start hands both over, the first
start reads the renewed A and B.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:32:00 +00:00
rdbtCVSandClaude Sonnet 5.5 c1348a3526 Armor stand DisabledSlots and another player's lead are locked
ArmorStand.interactAt ignored `DisabledSlots` (not even read or saved), so
locked stands could be emptied and filled. The stand now keeps the bitmask:
bit `filterFlag` disables a slot (an empty hand then takes from the main
hand), `+8` stops taking from it and `+16` putting into it
(`isDisabled`, `swapItem`), and the save writes it back.

A lead tied to another player was untied by anyone who clicked the mob;
only the player it is tied to unties it (`getLeashHolder() == player`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:31:01 +00:00
Ludovic Marie 998049b7dd Merge branch 'pr/F5561new' into fix5561 2026-11-01 02:30:58 +01:00
some-du6eandClaude Opus 5.5 63eb8a3b8c Fill far terrain to the horizon with a coarse backdrop while it loads
Main samples only the planned tiles, nearest first: the seam with the
near chunks is fine at once, but the far terrain ends in a hard edge
until the last of ~3000 tiles (Full) is in, minutes on a slow machine.

After the seam, sample a coarse backdrop first: each far tile's
ancestor two levels up (one where that reaches the seam), ~230 tiles,
about a tenth more work. Ready ancestors already stand in for missing
children; now no tile stands in within 3 chunks of the render distance,
so broad boxes never sit beside the near chunks. The horizon is covered
after under 1000 jobs instead of the last one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:30:26 -06:00
Ludovic MarieandClaude Opus 5.5 8d3e175d49 Native loot drops: the killing player only while the hurt memory lasts
LivingEntity.dropFromLootTable supplies LAST_DAMAGE_PLAYER only when
playerKilled (lastHurtByPlayerMemoryTime > 0), so NativeLootDrops.player is
None for a mob that dies to the environment after the memory ran out
(arismoko's review). Vanilla's own loot is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:30:06 +01:00
Ludovic MarieandClaude Opus 5.5 58f3f290e6 Block contact hooks: no sweep without mod blocks, vanilla's order and on-pos
- swept_inside returns at once when no loaded block is outside minecraft:,
  so a server's items and projectiles no longer sweep their blocks every tick
  with no content mod (rdbtCVS's hot-path note).
- An item's step_on comes before its inside contacts, as
  Entity.applyEffectsFromBlocks calls stepOn before checkInsideBlocks.
- A mob's step_on (and the magma floor, turtle egg checks with it) reads
  getOnPosLegacy through block_on_legacy, so a mob over the edge of its
  supporting block still steps on it (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:28:31 +01:00
rdbtCVSandClaude Sonnet 5.5 d4941b1155 Mobs keep the whole stack in each equipment slot
Mob equipment was an item id plus enchantments and a trim, so a picked-up
sword or armour piece lost its damage, name and components, a hand took one
item of a stack, a worn piece always compared as fresh, and death or
replacement dropped it repaired and unnamed.

`Gear.stacks` (the main hand keeps `main_hand_stack`) now holds the stack of
a slot that is more than one plain item. `set_item_stack` stores it,
`stored_stack`/`item_stack` read it back, replacing the item forgets it.
`equipItemIfPossible` takes the whole stack into a hand (one into an armour
slot) and drops the replaced stack as it was; `canReplaceCurrentItem` sees
the worn piece's damage and name; death drops the stack itself
(`Drop::Stack`), with the rolled damage when the drop is not preserved; the
save writes and reads it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:27:41 +00:00
JustKarkatandJustKarkat's AI agent ad13f8c39c Command-exec parity: a group's failures, counted by position
The summary charged a group with every mismatch whose command text also
appears in it, so two groups that probe the same thing (the orb groups'
`data get entity @e[type=minecraft:experience_orb,...]` lines) each
showed the other's failures. Positions are what the per-group ranges
already are; count by those.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:27:16 -05:00
TiggerTakkyTTTandCodex 49cdc5aa61 fix(render): match dragon death pose and rays to vanilla
Freeze flight-history interpolation after death and render death rays with
vanilla's depth writes, back-face culling and distance fog. Add a frozen-pose
regression and a vanilla oracle comparison for ray geometry and colours.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 08:27:16 +07:00
JustKarkatandJustKarkat's AI agent 564206afb7 Command-exec corpus: an orb boxed in, out of a block
The review of #5601 noted the new "experience orb fall" group never
reaches the one path of ExperienceOrb.tick that draws a random: the
push out of a block. This group pins it — a 3x3 block placed over a
settled orb leaves it colliding with only its top face free, so the
server nudges it up (Entity.moveTowardsClosestSpace) with gravity
skipped while its box is inside blocks, and it comes to rest on top of
the block. The draw's magnitude only decides how many ticks the escape
takes, so the checkpoints after it are the same either way; with the
push disabled the orb stays boxed in at y 215 and the group fails.

Corpus with both orb groups: 973/973 commands match.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:27:16 -05:00
Ludovic MarieandClaude Opus 5.5 064313363c Biome modifiers: skip what can't be read, with a warning
A modifier of a type not read yet (add_spawns, add_carvers, a mod's own
serializer) or naming a biome, feature or tag the root doesn't have (another
mod's ids, #c: convention tags) dropped every content mod. Real mods ship
those beside their feature modifiers, so that modifier is now skipped with a
warning naming the mod and file, as NeoForge skips an unresolvable holder
set (reservedbytes's review). A malformed modifier of a read type (unknown
step) still stops the build. The docs say #tag in features reads vanilla's
feature tags only for now (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:26:32 +01:00
Claude a307a24a63 Launcher: count the secrets file's keys before the start's hand-over
Review fix for the secrets file (#5095): a start counted what its failed
hand-over left in the file only afterwards, so a hand-over by another
process in between left it reporting the keychain away for those
accounts. The start now counts the file's keys first, and drops each
one it hands over itself.

Test: another process hands the account over right as this start's
hand-over fails; the start still reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:25:42 +00:00
some-du6eandClaude Opus 5.5 9148cb676f Native mods: the default-components docs name every reader and thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 -06:00
some-du6eandClaude Opus 5.5 33efc55c5b Native loot hooks: keep a replaced table's random_sequence; test the loot hook stays hookable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:36 -06:00
Ludovic MarieandClaude Opus 5.5 2c93d81d0c lan_e2e mount_other_level: horse and camel in the Nether by default
The whole matrix (7 mounts x 2 levels x 2 players + in_nether) takes about 10
minutes, and the nightly LAN suite already runs ~2,400 s of its 3,000 s
timeout (rdbtCVS's review). The default keeps the regression signal;
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:24:15 +01:00
Ludovic MarieandClaude Opus 5.5 934df75a12 Orbs draw their correction step; a knot is hit only within entity range
- Entity.commonTick sets the old position before the interpolation step, so
  the frame lerps from the pre-step position: previous keeps it now, where
  ExperienceOrb::tick had overwritten it and the orb still hopped (rdbtCVS).
- The knot is an entity, so a click counts as one on it only within the
  entity interaction range (filterHitResult), not the fence's block range
  (Joansitoh).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:23:26 +01:00
Ludovic MarieandClaude Opus 5.5 cb9e4996d3 OptiFine reader: paths stay in the root, linear globs, no link loops
rdbtCVS's review points 1-3:
- resolve returns None for a path with a .., . or empty segment, so a pack
  can never name a file outside the overlay root (vanilla refuses them).
- glob backtracks only to the last star: O(pattern x name), where every
  star tried every split before (a player-chosen item name, per frame).
- properties_files reads each real folder once and at most 16 deep, so a
  folder pack linking to its own parent no longer recurses to PATH_MAX.
Tests for each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:22:01 +01:00
Ludovic MarieandClaude Opus 5.5 8d98852911 Screen mouse hooks: buttons past 4 are the platform's numbers
winit's MouseButton::Other(n) is the raw X11/evdev number on Linux, not GLFW's
index, so the hook docs and the fragment say a button past 4 is
platform-defined (rdbtCVS's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:20:32 +01:00
rdbtCVSandClaude Sonnet 5.5 ca88f2e2e5 The dedicated server's autosave and save-all save every player
The player-file save in `save_game_metadata` only ran for a dedicated-mode
integrated server (the fixture), so a real `--dedicated` server (the
`DedicatedServer` role) skipped it on autosave and `save-all`: players were
only written when they left or the server stopped. It now runs for every
server role, as PlayerList.saveAll does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:19:35 +00:00
Ludovic MarieandClaude Opus 5.5 fef6f8080a Consume effects: an empty namespace is minecraft's
Identifier.bySeparator reads ":clear_all_effects" as minecraft:clear_all_effects
(Consumable.CODEC decodes both), so it is vanilla's clear, not a mod kind for
the native hook (arismoko's review). Test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:19:25 +01:00
reservedbytes db4208923e fix(saves): thrown snowballs, eggs and ender pearls are saved with their chunk 2026-11-01 00:18:57 -01:00
Ludovic MarieandClaude Opus 5.5 985647d587 Merge main into #5595's branch: lang files take main's, plus Built on {base}
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:18:50 +01:00
rdbtCVSandClaude Sonnet 5.5 1e3758f6c1 A removed vehicle empties its container and drops its stand-in
A chest boat or chest/hopper minecart open in a menu keeps its slots in a
stand-in under a placeholder key. Removing the vehicle (broken, killed,
exploded, a rider's death, a portal) dropped the vehicle's stale items and
left the stand-in with a full copy a stale menu could still take from.
`release_vehicle_container` writes the menu's live slots back to the vehicle
first (so they are what drops or travels), then removes the stand-in, before
each removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:18:10 +00:00
Ludovic MarieandClaude Opus 5.5 124cd2ac33 Dispenser placement: getRotation is -90 facing up or down
DirectionalPlaceContext.getRotation is get2DDataValue() * 90, so a vertical
facing gives -90, not toYRot's 270. Both name East for vanilla's blocks
(from_y_rot, rotation segments), but the placement hook shows the raw value
(arismoko's review on #5516). Armor stands and boats keep toYRot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:17:18 +01:00
rdbtCVSandClaude Sonnet 5.5 f79e3b7e60 Check a menu's stillValid before every guest click
A menu was only validated once per tick, after the packet phase, so a click
could still land on a vehicle that was gone, a mount that died or left reach,
a lectern out of reach or a station block that was replaced. The click
packets (container click, button, slot state, rename, beacon, trade select,
bundle select, place recipe) now run one shared check first: the open menu,
whichever kind, must still be valid, else it closes like ContainerClose and
the click lands on nothing. The per-tick checks use the same predicates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:16:19 +00:00
rdbtCVSandClaude Sonnet 5.5 a65385ff3e Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:15:36 +00:00
Claude f9fe596cba Launcher: a run started on an old secrets file finds it after a hand-over
Review fix for the secrets file (#5095): a launcher that started while
the keychain was away, on a file a previous run left, asked the
keychain again only for secrets it had written itself. Once another
process handed the file over, it reported the keychain away for
accounts the keychain now held.

It now also counts what it read from the file and what its own start
left there. Test: two launchers started on an old file while the
keychain is away (one read the account, one didn't) both read it after
a third hands it over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:15:21 +00:00
Ludovic MarieandClaude Opus 5.5 06584db009 Bone meal hook: a mod's no keeps the meal, as vanilla's growCrop does
native_is_valid_bonemeal_target answers Option<bool>: Some(false) refuses
the meal even where the block's template takes it (BoneMealItem.growCrop
returns false when isValidBonemealTarget is false), None leaves the
template's BonemealableBlock (arismoko's review). The client prediction
falls through to growWaterPlant on a refusal, as vanilla's useOn does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:15:16 +01:00
rdbtCVSandClaude Sonnet 5.5 ff6ef169c3 Every guest menu open closes the previous menu first
Each opener closed a different subset of the menus the guest could have
open (the trading screen left a mount or crafting state behind, a vehicle's
stand-in stayed, the old window id still took clicks). They now all start
with `guest_close_all`, as `ServerPlayer.openMenu` closes the open menu:
containers, vehicles, mounts, merchants, stations, lecterns, the crafting
table and native menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:13:35 +00:00
Aaron Wang ae2088d2ab Merge remote-tracking branch 'origin/main' into fix/armor-stand-lerp 2026-10-31 21:13:12 -04:00
Aaron Wang 642ed5b09b A stand riding the player is seated after the player's move
position_riders ran before the player moved and before previous_position
was saved, so a name hologram on the local player was drawn a full tick
behind it. Vanilla ticks the player (tickNonPassenger) before its
passengers (positionRider), so seat_own_stands now runs after the move,
from the previous_position just saved to the moved position.

The snap test now walks toward x 5 when the far sync comes and checks no
step back toward it is left, so it fails if a snap stops clearing the
handler. The ledger citations move off the first doc lines they were
read as ports from.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:13:11 -04:00
rdbtCVSandClaude Sonnet 5.5 322d45fda5 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:12:39 +00:00
rdbtCVSandClaude Sonnet 5.5 7a19762534 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:11:47 +00:00
Ludovic MarieandClaude Opus 5.5 4eb27f4216 Piston head: a neighbour change asks can_survive, so a mod's answer counts
PistonHeadBlock.neighborChanged calls state.canSurvive; the direct
piston_head_can_survive call skipped native_can_survive (arismoko's review).
can_survive keeps the piston rule as its default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:18 +01:00
some-du6eandClaude Opus 5.5 39e9a8e0d1 horizon-prepare: a stamp without outputs never matches; say what HORIZON_PREPARE_JOBS limits
stamp_matches compared a missing outputs key with the digest of a missing output as None == None. The docs and comments said HORIZON_PREPARE_JOBS is how many things run at once; it is how many threads copy and hash, the lanes all run at once unless it is 1, and it counts logical processors. With the split there are five lanes. java.rs's memory figure now says what it compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:08:43 -06:00
JustKarkatandJustKarkat's AI agent 58a141917f Item quads: cull back faces like vanilla's item render types
A held two-sided model carries a quad per direction (a calibrated sculk
sensor's tendrils and amethyst planes, big dripleaf, block/cross), and the
item render types vanilla picks in ItemBlockRenderTypes -- solidBlockSheet
(entitySolid), cutoutBlockSheet (entityCutout), translucentItemSheet
(entityTranslucentCull) -- all keep the composite state's CULL. Horizon
culled only quads whose sprite has translucent pixels, so held blocks drew
both sides: the coplanar pairs striped (#4856, #4855) and a hay block's far
faces bled through the near ones (#4801).

Two-sidedness in vanilla is geometry, not culling: ItemModelGenerator's
bakeExtrudedSprite bakes a South and a North quad per layer, which
horizon-assets already mirrors.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:08:25 -05:00
MariuslozoandClaude Opus 5.5 7ca3d98901 Single player: the owner's leave saves the file it loads; .dat_old fallback
The owner loads the file level.dat names, but when it left (save_guest,
PlayerList.remove) only its own file was written; the named one waited for
the world's save that follows (stop_integrated: free_player_beds, the
chunks, then save_game_metadata). A game stopped or failing in between
left the named file older than the own one, and the owner then loaded the
older state. `playerdata::save_owner` writes the named file
(`Level::write_player`, also the player half of `Level::write`) before the
own one, so the file the owner loads is never the older.

`playerdata::load` (PlayerDataStorage.load) now falls back to the player's
`.dat_old`, as vanilla does: `write_dat` moves the old file aside before it
renames the new one in, so a save cut off there leaves only the
`.dat_old`, which the named file's read already used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 3e75d6484b Port map: load_owner's row keeps the load and datafix gap; no stale citation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 be7de6230c Single player: the owner loads the player file level.dat names (#5351)
PlayerList.loadPlayerData gives the single-player owner the file level.dat
names (`singleplayer_uuid`), not their own. Since #5069 the integrated
server saves the owner to both files, `players/data/<singleplayer_uuid>.dat`
(offline `Player`) and `<owner uuid>.dat`, but loaded the owner's own file
first. A build that saves the owner to the named file alone (0.2.93 and the
stable releases before #5069) leaves the own file behind, so after
switching back the owner got an older inventory and position, and the next
save wrote them over the newer ones.

`Level::read` now says whether its player came from the named file, and
`playerdata::load_owner` applies it before the owner's own file; the own
file stays the fallback when the named file is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
Ludovic MarieandClaude Opus 5.5 28bd35e286 Merge main into #5414
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 f8fa3c9996 Merge main into #5441
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 e33808a648 Merge main into #5450
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:16 +01:00
Ludovic MarieandClaude Opus 5.5 9c77c753a5 Merge main into #5442
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:15 +01:00
Ludovic MarieandClaude Opus 5.5 8b9d3235e1 Merge main into #5433
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:12 +01:00
Ludovic MarieandClaude Opus 5.5 7fcd32faa1 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:11 +01:00
Claude f795e86510 Launcher: serialize keychain writes so a hand-over can't undo a sign-out
Review fixes for the secrets file (#5095):

- A start's hand-over wrote a snapshot of the file to the keychain, so
  a token another process signed out or renewed meanwhile could come
  back or be overwritten. Every keychain write now holds
  account-secrets.keychain.lock, and the hand-over reads each entry
  again under it. The file's own lock still never waits on a prompt.
- MCP secrets_file is true only while the file holds a sign-in: not
  for sign-out marks alone, nor for a file that can't be read.
- Tests: a hand-over held on a prompt while a running launcher signs
  out or renews the same account; what counts as a sign-in in the file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:03:11 +00:00
some-du6eandClaude Opus 5.5 b2a09a2118 OptiFine CEM: read .jem and .jpm entity models into vanilla-frame geometry
Parses OptiFine's custom entity model files: models, part, attach, scale,
baseId, .jpm part files, invertAxis, translate, rotate, mirrorTexture,
textureSize, texture, boxes (textureOffset box UVs or per-face uvNorth..
with their aliases, sizeAdd, sizesAdd, EMF's sizeAddX/Y/Z), submodel(s),
attachments and the animation expressions as text. Boxes bake into the
quads ModelPart draws, on vanilla's axes around the vanilla part's pivot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:14 -06:00
some-du6eandClaude Opus 5.5 5b16c46f5d horizon-prepare: the release check's repeat run compares the manifest too; four JVMs with the split
The repeat run's file check skipped horizon-resources.json, so a manifest the repeat rewrote differently passed. The comments that said at most three JVMs run at once now say four with the second server JVM.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:58:51 -06:00
TeiwazikandClaude Opus 5.5 5cc2d09d5c Shader packs: a Shader Options page for a Horizon pack's [[options]]
A Horizon pack declares options, but they stayed at their defaults: the player had no way to set them.
The picked pack now gets the same Shader Options... button as a shaders/ pack, and its values live in
shaderpacks/<pack>.txt, the file that kind already uses. A saved value counts as a change to the pack,
so it reloads with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:58:23 +01:00
Aaron Wang a484e2b491 The player predicate sees a burning player on fire
player_entity left on_fire false, so a criterion's player predicate with
flags.is_on_fire true never matched, a mod's trigger (the reviewer's
case) or a vanilla one. It is Entity.isOnFire on a server now:
remainingFireTicks above 0 (players aren't fire immune).

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 20:56:58 -04:00
Claude 82ea5d145f Launcher: never hold the secrets file's lock while the keychain prompts
Review fix for the secrets file (#5095): Layered asked the keychain
while holding account-secrets.lock, so an unlock prompt held up every
other launcher and CLI process.

- Each call reads its file entry under the lock, asks the keychain
  with the lock released, then removes the entry only if it still
  holds what was read: another process's newer write stays. The start's
  hand-over works the same way, from a snapshot.
- Once the keychain refuses or doesn't answer (a dismissed prompt),
  the run stops asking it and parks new secrets in the file, until it
  finds that another process handed the keychain one it parked.
- Tests: a keychain waiting on a prompt while another process reads and
  writes the file, a dismissed prompt asked once a run, and a half
  refused hand-over followed by a sign-out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:54:18 +00:00
TeiwazikandClaude Opus 5.5 bbbb18595f Shader API: [[materials]], an id by sprite, and shadows cut whole
A pack treats hundreds of kinds of surface differently, where [[tags]] hold 32 sets. Each sprite takes
the id of the first [[materials]] entry that names it, as MaterialInput.material, through the same
generated switch as the tags. [shadow] solid_materials draws those ids into the map without their
cutouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:43:03 +01:00
Claude ab8797a5bd Launcher: keep the Tailscale token out of the secrets file, say where sign-ins sit
Review fixes for the secrets file (#5095):

- The Tailscale access token administers the player's whole tailnet:
  it goes through account::keychain(), the keychain or memory for the
  run, never the secrets file.
- MCP account_list says secrets_file: true while sign-ins sit in the
  file, beside keychain (sign-ins outlast the launcher).
- launcher/README.md and the sign-out help (docs/launcher-commands.md,
  regenerated) no longer say tokens are never in a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:42:09 +00:00
SylviromiandDeepSeek V4.1 Flash 5a1f9ed66f Native mods: format the contributed-files test
rustfmt only (cargo fmt --all); no behavior change.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-01 03:39:42 +03:00
Claude b617e637b4 Launcher: read the keychain and the secrets file together on Linux
Review fixes for the secrets file (#5095):

- No store is picked once per run any more: on Linux and FreeBSD every
  read takes the file's copy first and the keychain's otherwise, and
  every write goes to the keychain, to the file only when it refuses.
  A launcher that wrote to the file keeps reading, renewing and signing
  out after another process hands the file to the keychain.
- Each start hands the keychain the file's secrets one at a time, each
  leaving the file once the keychain took it, so a move it half refuses
  never leaves a secret in both places, and signing out removes it.
- Signing out while the keychain is away leaves an empty entry that
  hides the keychain's copy until it can be deleted.
- macOS and Windows keep the keychain or memory, as before.
- The concurrency test now runs real child processes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:37:06 +00:00
Solenopsisbot 1c8a721dee Merge remote-tracking branch 'origin/main' into accounts/player-dir
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/api/launch.md
#	docs/api/stability.md
2026-11-01 11:31:48 +11:00
[email protected]andGPT-6 4497abec98 feat(mods): support image tooltip components
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 01:27:20 +01:00
Claude d1eca94f9c Launcher: lock the secrets file across processes, hand it back to the keychain
Review fixes for the secrets file (#5095):

- Every read-modify-replace holds account-secrets.lock (std File::lock),
  so launcher and CLI processes signing in or out at once no longer
  overwrite each other; each write uses a temporary file of its own.
- The file no longer sticks once made: at the first start the keychain
  answers, its secrets move into the keychain and the file is removed,
  so an account kept in the keychain before a start it missed comes
  back beside the ones signed in meanwhile. If the keychain refuses any,
  the file stays whole and in use.
- A broken file's error names only the error kind, line and column:
  serde's own message can quote a string the file holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:26:28 +00:00
SylviromiandDeepSeek 622c1a077a Native mods: data pack files in the server's data load
native_data_pack_files asks a native mod, while the server assembles its data
tables, for the pack files it contributes: (path, bytes) in the overlay's form.
It fires once per load — when a world opens, and again on every `/reload` — so
the ask is the mod's chance to recompute them from its config or from the
world. A mod that turns configuration into recipes, tags or loot needs no
content mod beside it and no data pack installed by hand: today neither can
recompute those files for a load.

The files join the load where the world's packs already do:
Game::build_world_tables is both a world's first open and reload_world_tables
(`/reload`, `/datapack`), and the hook is asked there and nowhere else. They
sit below those packs: PackOverlay::top is a path's last file, so a player's
data pack still overrides a mod's for the same path, and a later contribution
overrides an earlier one, as packs layer. The advancement prefetch takes the
same files, so a contributed advancement is there on the first open and not
only after a reload.

With no mod hooking it nothing is contributed: a world with no packs still
uses Resources.rest, and a world with packs builds exactly the tables it did
before.

The hook is API next (tools/native-hook-fragments/data_pack_files.json);
native-hooks.json and NATIVE_HOOKS_API are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 03:23:16 +03:00
TeiwazikandClaude Opus 5.5 11cd3f220d Shader API: the first-person player casts into the shadow map
The local player's model isn't drawn in first person, so a native pack's shadow pass gets it on its
own through the frame's shadow_only quads. The round blob under entities is left out while a pack
draws a shadow map, since the sun's shadow is already there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:16:22 +01:00
rdbtCVSandClaude Opus 5.5 14cbc388bb Launcher: list native builds on the Versions page, with Remove
Native mod builds only showed in the version picker, so there was no way to delete one by
hand. The Versions page now lists them above the releases, with what each was built on, its
size on disk, and the same Use and Remove (asked twice) as a release. Removing a version a
program still runs from is refused.

Fixes #5326

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:11:34 +00:00
Claude e7b5953ba4 Launcher: fix the secrets-file tests' names and comparison
kept() clashed with the tests' own helper, and Secrets has no PartialEq.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:04:41 +00:00
Sylviromi dd72fb8277 Merge branch 'screen-mouse-scrolled' into screen-key-input 2026-11-01 03:04:24 +03:00
Sylviromi c90161511b Merge branch 'screen-mouse-button' into screen-mouse-scrolled 2026-11-01 03:04:24 +03:00
Sylviromi 068b434a3d Merge branch 'screen-render-post' into screen-mouse-button 2026-11-01 03:04:24 +03:00
SylviromiandClaude Code ccb1474a03 Native mods: a server's trading window gives the screen view its title
remote_open_merchant left the menu title unset, so NativeScreenView.title was
None on that window although the docs promise the server's title.

Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:04:23 +03:00
7a6468bd8f Native mods: the screen hears of a key and of each typed character first
Adds screen_key_pressed_pre and screen_char_typed_pre (ScreenEvent.KeyPressed.Pre
and CharacterTyped.Pre) on the open screen, fired from the window's key event,
the control socket's key and type commands, and the input method's commit.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:02:51 +03:00
ErikKalikasandClaude Opus 5.5 600e7bec77 Native mods: test that infinite bounds skip a frustum that culls everything
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:38 +02:00
Claude e878abf1fd Launcher: keep Microsoft sign-ins in a file where no keychain answers
Many Linux systems run no Secret Service (GNOME Keyring, KWallet), and
the launcher then kept sign-ins in memory only: players signed in again
at every start (#5095).

Where the keychain doesn't answer, the secrets now go to
account-secrets.json in the launcher's data folder, readable by the
player only (0600 on Unix) and replaced whole through a temporary file.
The keychain stays first; a secrets file from an earlier start stays in
use so a flaky keychain doesn't sign players in and out, and signing the
last account out removes it. HORIZON_NO_KEYCHAIN still keeps secrets in
memory for the launcher's tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-10-31 23:58:12 +00:00
TeiwazikandClaude Opus 5.5 43207a5a3d Shader API: entities cast into the shadow map
The renderer's opaque entity quads now go into shadow_map through the shadow camera, with their
texture's cutout. They draw after the translucent map is copied, since where one casts the solid
map is the darker and decides. Unlit overlays, as entity.wgsl lists them, cast nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:57:54 +01:00
ErikKalikasandClaude Opus 5.5 4ad692a07b Native mods: leave native_draw_item and native_block_state_property for a follow-up
Keeps this pull request near the size limit; the block state stays in the
hooks, whose signatures can't change once merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:56:04 +02:00
SylviromiandDeepSeek b1d6819737 Native mods: the screen hears of the wheel before it scrolls
`native_screen_mouse_scrolled_pre` (`ScreenEvent.MouseScrolled.Pre`) hands a
mod the open screen, the wheel's movement in x and y as the game's scroll
handler already scales it (vanilla's doubles, the sign only when discrete
scrolling is on, a touchpad's pixels over 30) and GLFW's modifier bits, plus
`canceled`.

It fires in the `MouseWheel` arm once `scaled` is defined, before the dialog,
title, hotbar, bundle and every screen scroll, so a cancelled turn scrolls
nothing and leaves nothing half-applied; the hotbar reads the wheel only
while playing, which the hook's gate excludes. The pad's and the keyboard
focus's made-up wheel is not the mouse and fires nothing.

The hook doc names the screens that never fire, as the mouse button hooks
do, and the fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:51:37 +03:00
Teiwazik 3ab39ea984 Merge remote-tracking branch 'origin/main' into shader/s8-shadow-entities 2026-11-01 00:50:18 +01:00
ErikKalikasandClaude Opus 5.5 97c8e9ed15 Native mods: the block state in the block entity hooks, items and state properties for drawing
Both hooks get the block's state, as a renderer reads
blockEntity.getBlockState(); native_block_state_property reads its facing
without a world lookup, and native_draw_item draws an item stack under the
pose stack as a campfire does (resolve_item, submit_item_world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:46:43 +02:00
TeiwazikandClaude Opus 5.5 6a47c9eab5 Docs: shadow_color carries the vertex colour's shading
The colour is the mesher's vertex colour, so it includes the face's directional shade and ambient occlusion as well as the tint. The docs now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:38:31 +01:00
SylviromiandClaude Opus 5.5 4685ab06e2 Native mods: fragment names the renamed gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:45 +03:00
SylviromiandClaude Opus 5.5 7c42d32723 Native mods: the screen-hook gate is a helper, not a hook point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:34 +03:00
some-du6eandClaude Opus 5.5 42cfa789b9 Native mods: document the default-components hook, share the catalog scan
docs/native-mods.md gets an "Items' default components" section beside the
render hook points: the target path, both sides, threads, vanilla clients,
creative search. The catalog test reads free-function hooks with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:34:11 -06:00
some-du6eandClaude Opus 5.5 9cecb86ec2 horizon-prepare: review fixes for the parallel preparation
- A failed exporter compile fails every lane at once instead of being
  retried by each lane that waits for it.
- The gameplay facts exporter streams to a .partial file and moves it into
  place: a run stopped halfway no longer leaves a short file the next run
  would keep.
- pool::map takes no per-thread state (a jar clone costs two Arc counts);
  pool::lanes and map are tested at 1 job and several. With 1 job the first
  failing lane stops the rest, as documented now.
- The lanes' log and stage messages go through relay(), which a test now
  checks; block behaviour reuses export_on; the stamp inputs are worked out
  once; the fifth lane exists only when it splits; dead checks are gone.
- check_prepare_exporters.py also prepares with 2 jobs (the default path
  below 6 cores, 5 JVMs) and repeats that run, which must start no JVM and
  change no file.
- The docs say what HORIZON_PREPARE_JOBS does, that the split is not yet
  timed on 6 cores, and the split's measured ~400 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:51 -06:00
ErikKalikasandClaude Opus 5.5 ee34e1982a Native mods: cull mod block entities by the bounds their mod gives (getRenderBoundingBox)
native_block_entity_render_bounding_box lets a mod widen the box its block
entity is culled with (NeoForge's getRenderBoundingBox), or make it
infinite, so a tall part stays drawn while its block is out of view. The
view distance check is strict, as Vec3.closerThan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:33:44 +02:00
SylviromiandClaude Opus 5.5 5f87b9e60d Native mods: mouse hook docs, which screens fire
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:16 +03:00
MariuslozoandClaude Opus 5.5 bc13a0403f lan_e2e mount_other_level: the player in the Nether, and no clicks on the last mount
The other way round from a force-loaded chunk: the player and a horse in the
Nether while the other player keeps the Overworld ticking; the Overworld's pass
must leave the menu alone (in_nether, --server-thread). The player lands in
creative and is put back on the platform once it stands, so a cave below the
spot doesn't drop it out of reach.

A killed mount lies at the spot for 20 ticks (LivingEntity.tickDeath), and the
next one, summoned there 10 ticks later, took the clicks meant for it on a
slow run: wait until the client no longer sees it. LAN_E2E_LEVELS takes a
comma list (the_nether, the_end, in_nether), as LAN_E2E_MOUNTS does; the
force-load is removed even when the wait after adding it fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:27:22 +01:00
SylviromiandDeepSeek e39e0088cb Native mods: mouse button press and release on the open screen
`native_screen_mouse_button_pressed_pre` and
`native_screen_mouse_button_released_pre` (`ScreenEvent.MouseButtonPressed.Pre`
and `.MouseButtonReleased.Pre`) hand a mod the open screen, the button's GLFW
index (0 left, 1 right, 2 middle, 3 back, 4 forward, `Other(n)` = n) and
GLFW's modifier bits (shift 1, control 2, alt 4, super 8), plus `canceled`
for a press the screen never sees.

They fire first in `handle_window_event`'s closure, before the screen or the
container hotkey, sort and header buttons act, so a cancelled press leaves
nothing half-applied and a cancelled release keeps a drag held. The pad's and
the keyboard focus's made-up clicks are not the mouse and fire nothing.

The gate and the view two more input hook families need are here: the pure
`glfw_modifiers` and `glfw_button`, `native_screen_open` (a screen of the
game's over `Screen::Playing`, no connect or load) and `screen_view_now`,
which builds only the `HudBatch` its rect needs. The hook docs and the
fragment name the screens that never fire: the title screen, the connect and
loading screens, the prompts, books and sign editors drawn over gameplay, and
any screen while a world loads or saves.

The fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:25:08 +03:00
some-du6eandClaude Opus 5.5 d253b46872 OptiFine packs: a shared reader for their .properties, matchers and paths
New crate horizon-optifine, the layer every OptiFine-format feature (CTM,
CIT, random and emissive entities, CEM, sky, colours) reads its files
through: Java .properties with escapes and continuations, name matchers
(pattern:, ipattern:, regex:, iregex:), range lists with parenthesised
negatives, id lists with ! exclusions, block lists with states, OptiFine
path resolution (~/, ./, namespace:) and the optifine/ and mcpatcher/
folders of every namespace. Written from OptiFine's public format docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:52 -06:00
some-du6eandClaude Opus 5.5 619b5fe709 Native mods: change the default components of any item (API next)
A mod could not change a default component of an item it didn't add (a
stack size, a food, a tool rule), as NeoForge's ModifyDefaultComponentsEvent
lets it. native_modify_default_components hands a mod each item's defaults
in items.json's form as they are read, to set or remove components.

Every reader of items.json (the item table, loot, enchanting, item models,
banners, pot sherds, rarity in chat) now goes through
horizon_world::item_defaults::read, so a change reaches all of them, and
stacks are saved and sent against the changed defaults like vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:27 -06:00
TeiwazikandClaude Opus 5.5 89be6259da Docs: the dither option's row has three cells
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:16:39 +01:00
Teiwazik b10619ee4f Merge branch 'shader/s6-shadow-solid' into shader/s7-shadow-translucent
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_shadow.rs
2026-11-01 00:16:37 +01:00
TeiwazikandClaude Opus 5.5 d5316caaaf Shader API: review fixes for [shadow]
The depth range now reaches past the build height below the camera and shadow_position clamps z,
so a point past the far end stays lit, the blank map included. The light's box culls the native
lists instead of a section radius that lost casters at low sun. The native camera's snap is
euclidean, the resolution is clamped once, and the distortion and the camera each live in one
place. The generated reference lists the shadow helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:14:36 +01:00
huiandClaude Opus 5.5 36b0d2670f Native mods: enchant_effect honours requirements and names the mob hit
The first cut told an entry's kind before its requirements were checked, so a
failed requirement still fired, and gave the hook no way to know which mob was
hit. The kind is now told after the check, with the mob's id. A bare type name
is minecraft's own, so ignite runs as minecraft:ignite instead of reaching the
hook. The docs now say what's told: attacker on victim, a player's weapon on a
mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:13:18 +01:00
some-du6eandClaude Opus 5.5 5bc2501d7f Native loot hooks: clippy's auto-deref
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:58 -06:00
some-du6eandClaude Opus 5.5 c3e5e7866c horizon-prepare: block behaviour on a second server JVM with 6+ cores
A fresh preparation on a 6-core laptop finished its other JVM lanes by ~17 s,
then ran the server lane alone for ~20 s more: its last export, the world
generator's block behaviour (11.5 s there), sat behind everything else on
that JVM. From 6 cores (pool::jobs, so HORIZON_PREPARE_JOBS too) it and the
structure blocks run on a fifth lane, in their own server JVM. It pays a
second Bootstrap and ~400 MB of peak memory, so it stays off below 6 cores:
forced on this 3-core box it took 44.9 s against 40.5 s.

Same output (digest unchanged either way). check_prepare_exporters.py now
runs its batched preparation that way and expects 6 JVM launches; it passes
(23,204 files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:12 -06:00
some-du6eandClaude Opus 5.5 75047c93d9 horizon-prepare: cut its memory peak from 1.47 GB to 1.06 GB
Measured on the pinned 26.3 jars, sampling every process's RSS: the peak came
at the end, from the server worker alone (1.25 GB). Its live heap stayed under
~250 MB; the rest was garbage the serial collector promoted and kept, mostly
from two exporters:

- The collision exporter wrote, and the worker cached, 62 MB of verification
  inputs (printf'd, ~4 s) that only the parity harness reads. Inside the
  prepare worker it now skips them; standalone runs still write them.
- The gameplay facts exporter built its 56 MB file as a Gson tree, then a
  String, then bytes. It now streams each state to the file; the bytes are
  the same (re-serializing the parsed file with Gson.toJson gives them back).

The worker now peaks at ~480 MB. In the Rust process, every thread reading a
jar parsed its member list again (~10 MB each, eight at once); they now
share one parse through clones of the archive that read at their own offsets.
Its heap peak halved (96 to 51 MB).

check_prepare_exporters.py passes (23,054 files, 5 JVM launches); the output
digest is unchanged. "Movement and attributes" takes 12-13 s instead of 16-18 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:10 -06:00
Teiwazik b13a22fdcb Merge remote-tracking branch 'origin/main' into shader/s6-shadow-solid 2026-11-01 00:06:32 +01:00
huiandZCode AI agent (GLM-5.3) f074c2d4bd Native mods: an enchantment's effect of a mod's own kind
An enchantment's effects entry that names an effect.type in a namespace
outside minecraft: used to be silently skipped at the firing point. It
now reaches native_enchant_effect(enchantment, kind, data, level) — the
post_attack components first, where the weapon's holder is the acting
player — and the game's own damage, sounds, effects and drops are open
to the hook. An unclaimed kind does nothing.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 00:06:13 +01:00
some-du6eandClaude Opus 5.5 a9d6eaf3b3 horizon-prepare: serial collector for its JVMs; time steps after compiling
The serial collector is as fast as the parallel one for these JVMs (fresh run
63-66 s either way, pinned 26.3 jars) and keeps the three that run at once to
1.25 GB together instead of 2 GB. An export's time no longer includes waiting
for javac. A compile error still names the export it stopped.

tools/check_prepare_exporters.py passes: 23054 resource files match between two
isolated runs and a batched one, with 5 JVM launches and no compiler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 a935fe0bf2 horizon-prepare: run its JVMs as batch jobs (C1 only, ParallelGC)
The exporters, the data generator and javac are short batch JVMs, three of them
running at once. The C2 compiler's background work and G1's concurrent threads
cost them more CPU than those win back. With -XX:TieredStopAtLevel=1
-XX:+UseParallelGC (javac gets them through -J), a fresh preparation from the
pinned 26.3 jars took 67 s instead of 102-105 s on the same machine. The output
digest (tools/prepare_output.py) is unchanged. java.rs is not part of the
exporter bundle key, so cached bundles stay valid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 78d211e0a4 horizon-prepare: run independent steps at once, skip unchanged exporters
Fresh preparation ran everything one after another. The steps now run on four
lanes: the server exporter JVM (same order as before, since its exporters share
state), the client exporter JVM then the command registries', the data generator
then the creative inventory's, and the Rust-only copying. At most three JVMs run
at once, still five JVMs in total. A worker stops when its lane is done. The two
exporter sides compile at once. Asset extraction, structure templates, worldgen
bundles, server library unpacking, client library imports, inventories and pin
checks run on several threads. Every pinned file is still hashed in full.

Repeat runs always started the server JVM for the block behaviour and
structure-block exporters, which had no skip check. They now keep a stamp.json
in .local (exporter bundle key, server jar, preparer sources, output sha256), so
an unchanged repeat run starts no JVM. The prepared files are unchanged.

Every step logs how long it took. HORIZON_PREPARE_JOBS=1 runs it all serially.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:56 -06:00
MariuslozoandClaude Sonnet 5.5 276b77a0f0 lan_e2e: mount_other_level covers every mount, the End, getting off and reopening (#5331)
The check now opens a tamed horse, camel, llama, trader llama, nautilus
(owned), chested donkey and mule by shift + right click and by the
inventory key while riding, and again by shift + right click after getting
off, on the host and on the guest, with a chunk force-loaded in the Nether
and in the End. Every way is reported on its own and must still be open 20
ticks later.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 00:05:40 +01:00
some-du6eandClaude Opus 5.5 8226fb0b51 Native loot hooks: a mob's drops name its position as the origin
LivingEntity.dropFromLootTable's ORIGIN is the mob's position, not the point
its drops spawn from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:52 -06:00
SylviromiandClaude Opus 5.5 dc6b189768 Native mods: the screen view's rect is the drawn window; menu None when unknown
The rect comes from the same container image the game draws, so the
merchant and the beacon report their wider, shifted window. An unknown menu
type is None rather than an empty id, and slot_count is checked against
slots() in debug builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:41 +03:00
v3nn7andCodex 3cdf2466ae Test steering items in both hands
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:48 +01:00
v3nn7andCodex 88c6720079 Allow steering mounts with offhand item
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:42 +01:00
some-du6eandClaude Opus 5.5 0c987c9f63 Native loot hooks: round 3 fixes
A server names no player of its own as a mob's killer, and the drops
context's fields aren't flagged as never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:21 -06:00
SylviromiandDeepSeek dfcc1eb4dc Native mods: the screen hook's payload, and a view that does not allocate
`native_screen_render_post` (`ScreenEvent.Render.Post`) hands the mod the
mouse in GUI pixels and the frame's partial tick besides the screen view,
as the event's `mouseX`, `mouseY` and `partialTick` do, so a mod can draw a
hover highlight.

The view also carries `menu`, the vanilla menu type of the menu the server
opened (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id), and
None for a window the client opened. Every container, station and mod
window draws on the inventory screen, so `id`'s doc says they all report
`inventory` and are told apart by `menu`, `title` and `slots`.

The view is built without allocating: slots are counted the way `Game::slots`
lists them (`slot_count`, with `station_slot_count`, `mount_slot_count` and
`native_screen_slot_count` beside their list versions) instead of being
collected into a `Vec` every frame, and the id comes from the screen
`hud_layers` drew rather than from a second argument beside `self.screen`.

The hook and the fragment say which screens never fire it: the title screen,
which returns before its point, and the prompts, books and sign editors
drawn over gameplay after it, which keep `Screen::Playing`, are skipped, and
draw over what the hook drew. Vanilla fires Render.Post for them; that gap
is now written down.

With no mod installed the body stays `let _ = (...)`: the frame is vanilla's.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:55:58 +03:00
rdbtCVSandClaude Opus 5.5 6ebfcd0ded Spawner minecart: /summon loads its spawner; review fixups
- /summon minecraft:spawner_minecart with no NBT runs BaseSpawner.load on an
  empty compound, as SummonCommand.createEntity does, so SpawnPotentials is
  WeightedList.of(new SpawnData()) rather than empty.
- The save test passes no raw tag and changes the delay after loading, so it
  checks the cart's own save arm.
- The cart model test covers spawner_minecart#main.
- Port ledger: MinecartSpawner's tick, save, load and display state are ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:46 +00:00
rdbtCVSandClaude Opus 5.5 0ddaee3da8 Native hooks: a villager bonemeals a mod block only when it is a crop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:24 +00:00
rdbtCVSandClaude Opus 5.5 75c6194615 lan_e2e: realign the riding entry of the check list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:51:18 +00:00
huiandZCode AI agent (GLM-5.3) 1742791286 Native mods: a consumable's effect of a mod's own kind
The loader dropped an on_consume_effects entry whose type the game
doesn't know silently. It now keeps the kind and the JSON the file
wrote, and the game hands them to native_consume_effect when the item
is consumed, on the server thread, for the acting player. The game's
own effects, sounds, teleports and drops are open to the hook; an
unclaimed kind does nothing, as an unknown kind on any loader.

A bare type name without a namespace is minecraft's own — apply_effects
is minecraft:apply_effects — so only a namespaced kind outside
minecraft's becomes Native; an unknown minecraft: kind is dropped. The
hook sees the item's own consumable component, not a patch on the stack.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:50:37 +01:00
some-du6eandClaude Opus 5.5 7f9f86a7ab Native loot hooks: round 2 fixes
The wall-table test's registry has every block's default, a kill by a guest
who left names no player, and the load hook's docs say two loads can overlap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:50:04 -06:00
huiandZCode AI agent (GLM-5.3) 1841925457 Ingredient shapes: compound is the union, intersection the all-of; ingredient_type key; fluid dropped
NeoForge's CompoundIngredient matches if any child does — the union of
their items, not the intersection (that is IntersectionIngredient).
The ingredient_type key is neoforge:ingredient_type, type only as the
older packs' spelling; children is the field name, ingredients as the
older spelling. neoforge:fluid is not among NeoForge's item ingredient
types (its fluid ingredients are a separate registry for fluid stacks,
not items), so it is dropped until a use case arrives with a source.

Review: rdbtCVS's agent in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:49:30 +01:00
Claude 4a361d034b native_summon: a root a mod kept out has no id
spawn_loaded_entities hands out the id before native_entity_joins runs,
and a cancelled orb doesn't give it back, so the fallback returned an id
no entity holds. Take it only when an orb holds that id (root_id), and
test both cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 22:48:42 +00:00
some-du6eandClaude Opus 5.5 603fda280e Native loot hooks: review fixes
Empty stacks a mod leaves don't drop, the load hook runs outside the
cache's lock, a mob's context names the table that rolled (one helper with
drops::die), the wall-variant table lookup has a test, and the docs say
which breaks the drops hook hears.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
some-du6eandClaude Opus 5.5 f749bbeef1 Native mods: change vanilla loot tables and drops
native_loot_table_load (horizon_world::loot) hears each loot table's JSON as
it loads, after the data packs' (LootTableLoadEvent, LootTableEvents.MODIFY),
and native_loot_drops hears a broken block's or a dead mob's items with what
dropped them (global loot modifiers, MODIFY_DROPS).

Closes #5522

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
ElyTheCatandCodex 8f785872f8 Remove redundant render upload diff
Keep the PR focused on worker-side side sorting.

Co-Authored-By: Codex <[email protected]>
2026-11-01 02:14:05 +03:30
SylviromiandDeepSeek 3e5b8b3ecc Native mods: draw over the open screen
Adds `screen_render_post`, the point of NeoForge's `ScreenEvent.Render.Post`: once a frame, after the
open screen has drawn itself and its tooltips, a native mod may draw over it. It is handed a
`NativeScreenView`: the screen's id (one per `Screen` variant, from `Game::screen_id`), the open
menu's title (borrowed from the menu, not copied: no per-frame allocation), the container panel in GUI
pixels or the whole window, and the menu's slot count.

The firing site is the end of `hud_layers`'s screen match, where every screen but Win and Options is
drawn. Those two keep their early returns: folding them into the match would draw the HUD and the
prompt path behind them, so the hook fires on their own path, after their drawing, instead. It does
not fire while playing with no screen, and it is a `&self` hook on the window only, so a mod can
read and draw but not change the screen.

With no mod installed the body is `let _ = (...)`: the output stays vanilla's.

The hook is API next (`tools/native-hook-fragments/screen_render_post.json`); `native-hooks.json` and
`NATIVE_HOOKS_API` are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:38:39 +03:00
rdbtCVSandClaude Opus 5.5 de0c665692 Scaled screenshots: halve the size when video memory runs out, not crash
An 8x screenshot at 200% Resolution Scale with the video memory already full
crashed: the depth buffer's allocation failed, and its view raised a
validation error that only an out-of-memory scope was watching. The target
and depth buffer, and the whole scaled frame, now run inside out-of-memory
and validation scopes; a failure writes no PNG and tries again next frame at
half the size, down to the window's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:37:16 +00:00
ErikKalikas 30350feba8 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
#	crates/horizon-client/src/game.rs
2026-11-01 00:36:06 +02:00
ElyTheCatandCodex 31456cb738 Move leaf draw ordering into mesh workers
Sort solid cutout quads while the worker-side side buckets are built, so rendering uploads the canonical worker result without a render-thread permutation or duplicate allocation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:56:35 +03:30
Kacpherek 0bf4904595 Stop riding a remote vehicle that is gone
A rider kept its vehicle id after the vehicle left the level. A respawn into
another level keeps the id when KEEP_ENTITY_DATA is set, and a removed
nautilus or minecart leaves it behind. remote_ride then kept the player in
the riding pose with no vehicle under it: it did not move, sneaking did
nothing, and the seat stayed until a vehicle packet named it again.

Vanilla Entity.baseTick stops riding a vehicle that is removed
(isPassenger() && getVehicle().isRemoved() -> stopRiding(), Entity.java).
Do the same in remote_ride when no minecart, boat, cushion or mob has the
id, as tick_riding already does on the local path (boats.rs).
2026-10-31 23:25:37 +01:00
Federico TaglianiandClaude Sonnet 5.5 b5d2db2e60 Tadpole: AbstractFish goals and swim, TadpoleAi brain
A tadpole is an AbstractFish with a brain: it gets panic 1.25, avoid-player and
the fish random swim goals, SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true),
SmoothSwimmingLookControl(10), water-bound navigation, and the TadpoleAi brain
(panic, look at player, follow temptation, swim/walk/stay-in-water gate).
Its travelInWater is AbstractFish's (0.01 speed, 0.9 drag, -0.005 gravity).

Adds mob_ai parity scenarios tadpole_idle, tadpole_tempted, tadpole_panic.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:24:16 +01:00
[email protected]andGPT-6 69c0f83d85 docs: clarify landing hooks preserve block-specific sounds
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:20:13 +01:00
[email protected]andGPT-6 e1c1669993 feat(mods): add server-side block contact hooks
Adds step-on, inside and fall-on callbacks with entity and block context; landing damage is adjustable before sounds and hurt.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:19:26 +01:00
sirrayiandDeepSeek Harness 86165f5754 Native mods: the placement fragment's summary is one JSON string
The hardening edit put a raw newline inside the fragment's `doc_summary`, which
JSON reads as a control character: `catalog_matches_hook_points` failed to
parse it. The line break is the `\n` escape the other fragments use.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:15:22 +00:00
MariuslozoandClaude Opus 5.5 8b5a93cbd0 Keep a horse inventory open while another level ticks (#5331)
tick_lan_mount_menus checked every open mount menu on every level pass,
not only in the player's own level. When a second level ticks (a player
or a force-loaded chunk in the Nether, a portal ticket), its pass found
no such mob and closed the menu the tick it opened. Like the station,
crafting and merchant ticks, it now skips players not in the level in
place (ServerPlayer.tick checks stillValid in the player's level).

lan_e2e mount_other_level force-loads a Nether chunk and checks that the
host's and the guest's horse inventories stay open, opened with shift +
right click and with the inventory key while riding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 +01:00
sirrayiandDeepSeek Harness 2aec9b416e Native mods: the placement hook's test covers a dispenser too
The issue asks the hook to run for every placer that goes through vanilla's
path, not only the player. `the_level_chooses_the_state_a_block_is_placed_in`
now drives a dispenser's `DirectionalPlaceContext` as `dispense_shulker_box`
builds it — `directional_can_place`, then `place_block` — and asserts the
furnace lands in the hook's state, with the hook handed the dispenser's own
click (its yaw, the front block it replaces).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:09:58 +00:00
sirrayiandDeepSeek Harness 6c494834f5 Native mods: a placement answer must be the placed block's own state
Review point on #5516: an answer naming another block would place a block the
item isn't bound to, and skip that block's `setPlacedBy` and sound. Vanilla's
`getStateForPlacement` sets properties on the block's own `defaultBlockState`,
so the hook's answer is taken only for a state of the block being placed;
another block's, like an id past the state table, leaves vanilla's state.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:07:45 +00:00
sirrayiandDeepSeek Harness 0be8029be8 Native mods: bone meal on a mod block
A mod block took no bone meal: `BoneMealItem.growCrop` looked the block's
`BonemealableBlock` up in the port's classes and stopped. The level now
answers vanilla's three questions for it, ahead of the `BonemealableBlock` a
content template would give it, so a mod block can refuse, roll and grow:

- `is_valid_bonemeal_target` — `isValidBonemealTarget`. Asked on both sides,
  as vanilla asks it, so a client predicts the use with the mod's answer.
- `is_bonemeal_success` — `isBonemealSuccess`, drawing from
  `native_level_random` so a mod's draws stay in vanilla's order.
- `perform_bonemeal` — `performBonemeal`, with the level's ticks.

The hooks are `native_is_valid_bonemeal_target`, `native_is_bonemeal_success`
and `native_perform_bonemeal` (a fragment, no API number taken), plus
`native_level_random` for a world hook, the stream `native_level_random
(dimension)` hands a server hook.

One insertion point serves every placer: `Sim::grow_crop` is where the
player's `BoneMealItem.useOn`, a dispenser's `BoneMealItem.growCrop` and a
villager's bone meal meet, and the meal is used up and level event 1505 fired
by the callers as before, so a mod block gets vanilla's particles and sound.
The client's copy of the level now forwards the one read-only question
through `DryLevel`, and a mod block's growth particles stay inside it, as
vanilla's `GROWER` type does, where the port knows no type for it.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:05:15 +00:00
ElyTheCatandCodex 095b522455 Optimize alpha-tested leaf rendering order
Reorder solid cutout quads front to back during GPU upload while preserving the canonical section mesh and shared index batching. Include the before and after leaf-scene captures used for validation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:33:08 +03:30
rdbtCVSandClaude Opus 5.5 02caa53a27 Scaled F2 screenshots: game resolution times a Screenshot Quality option
F2 now saves the frame at the game's resolution (Resolution Scale above
100%) times a new Video Settings option, Screenshot Quality (1x, 2x, 4x,
8x; 1x by default). The frame is drawn once into its own offscreen
target, with the HUD zoomed to the same layout, and capped to the GPU's
texture and buffer limits; if the large targets can't be allocated the
window's frame is saved as before.

Fixes #3030

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:01:44 +00:00
rdbtCVSandClaude Opus 5.5 2f7dd09e90 Windows import check: uiautomationcore.dll ships with Windows (GPUI Fast's accesskit_windows imports it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:58:38 +00:00
rdbtCVSandClaude Opus 5.5 4e61981299 Merge #3801's branch (with main) into #3825
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:51:17 +00:00
Kacpherek 67c4b573ad Smooth an experience orb's server corrections over 3 ticks
ExperienceOrb.createInterpolationHandler returns a LinearInterpolationHandler, so a client spreads each server position correction over 3 ticks (doInterpolate in Entity.commonTick) and the orb's own move carries the target along (applyPredictedMovement). The remote path put the orb at the new position at once, so it jumped on every correction; the comment claimed vanilla's NO_OP handler.
2026-10-31 22:45:25 +01:00
Kacpherek 6d2682cf87 Take leads back or snip them only when the click hits the fence knot
LeashFenceKnotEntity.interact runs only when the ray meets the knot (its box from recalculateBoundingBox, 0.375 wide, y + 0.375 to y + 0.875) before the post. A click elsewhere on the fence is FenceBlock.useWithoutItem or LeadItem.useOn, which only ties the player's own mobs (LeadItem.bindPlayerMobs). interact_knot ran the take-back and shears branches for any fence click, so clicking the top of a post with stone took a tied horse's lead instead of placing the stone, and shears snipped every lead.
2026-10-31 22:45:25 +01:00
rdbtCVSandClaude Opus 5.5 72e89aca4a Splitscreen: say Linux's gilrs matches a reconnected pad by model too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:44:53 +00:00
rdbtCVSandClaude Opus 5.5 19c46b3fde Biome modifier ore test: the configured feature in 26.3's flat form
The prepared root holds features without vanilla's old "config" wrapper and
with a block state as an id, so the zinc ore never decoded and the
content-root test failed over real data (CI skips it without .local/).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:43:01 +00:00
rdbtCVS 500c13a0bf Merge branch 'pr-3794' into pr-3801 2026-10-31 21:42:23 +00:00
sirrayiandDeepSeek Harness 09a67d8c48 Native mods: a mod block chooses the state it is placed in
`getStateForPlacement` had no way in for a mod: a block could only be placed
in its default state. `LevelAccess::before_state_for_placement` now asks the
level, once per block whose placement state is computed, with vanilla's
`BlockPlaceContext` (the clicked face, the placer's rotation, the clicked
position, whether the clicked block is being replaced, inside, the item in
hand) and the fluid of the position the block would go in, so a block can
face the player, sit on the clicked face or come waterlogged. The answer is
`Some(Some(state))` to place that state, `Some(None)` to refuse the placement
as vanilla's `null` does, and empty to let the block's own placement run.

`native_state_for_placement` is the hook a native bundle answers with, and
`native_state_with_property` builds the state from the block's default state
and one of its properties (a content block's declared ones included).

`Ctx`, the resolved context, is now `PlacementContext`, public and documented
as what a mod block is handed. Both placers that go through vanilla's path
reach it: the player's `BlockItem.place` and a dispenser's
`DirectionalPlaceContext` (both `Sim::state_for_placement`), and the answer
still takes its survival and entity obstruction checks.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 21:41:50 +00:00
rdbtCVSandClaude Opus 5.5 6d1237c04a Merge main into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:39:49 +00:00
rdbtCVS 746bdf8a96 Merge remote-tracking branch 'origin/main' into pr-3794 2026-10-31 21:39:39 +00:00
rdbtCVS 288ba8ca10 Merge branch 'pr-3777' into pr-3794 2026-10-31 21:39:34 +00:00
rdbtCVSandClaude Opus 5.5 f7bdb7449e Merge main into #3777
Main's handlers take a loop handle (EventLoopCtl) and about_to_wait's body is Game::idle: exit_on_error takes the handle, the split guard stays in idle, and run_event_loop stops the narrator after the split's loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:34:01 +00:00
levzzz eaf3bf704d Launcher: the AppImage path test runs on Unix only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 39696d7a74)
2026-11-01 02:33:31 +05:00
TeiwazikandClaude Opus 5.5 11843ac18b Mipmaps: put the cutout dither behind a graphics option, off by default
The dither changes vanilla's look for every cutout from 64 texels up, so it now runs only under
Distant Plant Dither (ditherCutoutMips in horizon-graphics.txt). Turning it on or off reloads the
textures, since mips are made when they load. Decoded textures don't hold mips, so the sprite cache
keeps its version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 22:30:41 +01:00
Aaron Wang e103983b5d Native mods fire their own advancement criteria triggers
An advancement of a content mod or a data pack can name a trigger of
the mod's (mymod:pipe_connected); the tree keeps it, but only the game's
own events called Game::trigger, so nothing could grant it.

native_trigger_criterion(uuid, trigger, test) is vanilla's
SimpleCriterionTrigger.trigger for one player: each open criterion of
that trigger goes to the mod's test, then to its player predicate, and
those that pass are granted and awarded as the game's own are. The test
reads the criterion's conditions and can check a vanilla item predicate
in them. It reaches the acting player inside their hook, and any player,
in their own level (for_player), where no player is swapped in;
minecraft: triggers are refused.

Fixes #5481.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 17:25:54 -04:00
levzzzandClaude Opus 5.5 6f0839a15e Launcher: the macOS and Windows tests build on gpui-fast
Keystroke::parse takes a 'static str; the app comes from gpui_platform::application(), and
AsyncApp::update returns nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:16:39 +05:00
PzmuV1517 b1f3afcd9a Native mods: a mod block decides if it can stay where it is
native_can_survive answers BlockState.canSurvive before the block does: as it is placed (BlockItem.canPlace)
and from the shape update of a block whose template checks it, which breaks it with its drops.
native_may_place_on answers VegetationBlock.mayPlaceOn, the blocks a mod plant can stand on, which its
template's canSurvive asks. Both through LevelAccess, on authoritative servers, as native_update_shape is.
2026-10-31 23:14:07 +02:00
rdbtCVSandClaude Opus 5.5 3332137ad2 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:12:15 +00:00
rdbtCVSandClaude Opus 5.5 85c781e56e Far terrain client: a reconfiguration frees the old server's slots and rate
Inbox::reset, called on Event::Reconfiguring after clear_lod: requests a proxy's previous backend held are forgotten instead of holding in_flight slots for the 30 s timeout, and the next offer's per_second makes a new bucket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:11:15 +00:00
rdbtCVSandClaude Opus 5.5 5a2f191b22 fix(skins): a head's texture patch only names a file inside the packs
The profile's texture id comes from a server's NBT; Identifier's characters and
no '.', '..' or empty segment keep it from reading a PNG outside the resource root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:46 +00:00
rdbtCVSandClaude Opus 5.5 29f5ee49ee Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:22 +00:00
rdbtCVSandClaude Opus 5.5 b42b510cfb Merge main into #4250
The ten lang tables conflicted where both sides added strings: main's table as the
base, plus this branch's account strings, without the "Microsoft account · signed in"
this branch removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:52 +00:00
rdbtCVSandClaude Opus 5.5 cf7b5ce636 Merge #4946 (main merged) into #4947
Conflicts: lod.rs (main's wanted-tile heap from #4747; a failed tile stays out of the heap until its RETRY passes, then the heap is rebuilt so it is asked again; main's MAX_LEVEL 22) and remote.rs (send_far_terrain and main's tick_chat_session both run before ClientTickEnd).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:25 +00:00
SolenopsisbotandClaude Opus 5.5 0294c117fe --player-dir: one player's own files when several games share a game folder
Two games playing in one game folder at the same time (#4952, the launcher's
`play --account`) fought over its per-player files: one `options.txt`, one
`logs/latest.log` (the second game logged to the terminal only), one generated name in
`horizon-player.txt` (two offline players got one UUID) and one `servers.dat`.

`horizon-client --player-dir DIR` puts this player's own files in DIR: `options.txt` and the
settings files beside it, `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`,
`hotbar.nbt` and `screenshots/`. Its players still share what the game folder holds: saves,
resource and shader packs, mods (the enabled list included, `logging::shared_dir`) and native
mods' `config/`, which no longer follow `options.txt` but the game folder (`Game::game_dir`).
Without the option nothing moves.

Launch API 0.1.1 promises the option (docs/api/launch.md).

Tested: two games started at once in one folder with --player-dir players/Alice and
players/Bob each logged to its own players/<name>/logs/latest.log and saved their worlds in
the shared saves/; a third, without --name, kept options.txt, horizon-graphics.txt and its
generated horizon-player.txt in players/Carol/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:08:11 +11:00
rdbtCVSandClaude Opus 5.5 68b5f9e525 native_play_local_sound: check the event with sound_id, as native_play_sound does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:03:25 +00:00
rdbtCVSandClaude Opus 5.5 53cfb35095 Merge main into #5441
Both sides added an Extra field and its NBT round trip (rabbit MoreCarrotTicks here,
ocelot Trusting on main); kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:00:23 +00:00
rdbtCVSandClaude Opus 5.5 5c3017a630 fix(mobs): a fox egg's trust goes to the baby made on a fox, not to an egg used on a block
Fox.onOffspringSpawnedFromEgg's only caller is Mob.checkAndHandleImportantInteractions
(a fox egg used on an adult fox), so the trust moves from use_spawn_egg to
spawn_egg_offspring. The port map's Fox.clearTrusted and readAdditionalSaveData rows
now say what is ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:59:05 +00:00
rdbtCVSandClaude Opus 5.5 27fb1b40c6 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:57:32 +00:00
rdbtCVSandClaude Opus 5.5 82bd9f6f37 Merge main into #5403
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:56:22 +00:00
levzzz 2034a32866 Merge remote-tracking branch 'origin/launcher/gpui-fast' into launcher/gpui-fast 2026-11-01 01:55:57 +05:00
levzzzandClaude Opus 5.5 124904afa4 Docs: the launcher reference lists completions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:54:47 +05:00
Claude f2d940e4c8 Biome modifiers: test one biome id and a list of biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:54:36 +00:00
levzzz 26a4cdc5c5 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 01:53:36 +05:00
rdbtCVSandClaude Opus 5.5 a844d55815 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:52:06 +00:00
rdbtCVSandClaude Opus 5.5 8e6281bb91 Merge #4945 (main merged) into #4946
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:51:26 +00:00
Guilherme Jansen 4f1ffce47d fix(launcher): preserve running macOS app bundles during updates 2026-10-31 17:49:40 -03:00
rdbtCVSandClaude Opus 5.5 e05059a562 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:49:11 +00:00
rdbtCVSandClaude Opus 5.5 7f4784397a Far terrain wire: columns hold main's Spans
#4747 made lod::Column::spans a SmallVec-backed Spans; the decoder builds one directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:13 +00:00
rdbtCVSandClaude Opus 5.5 88590156f0 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:46:21 +00:00
rdbtCVS f97c2cd499 Merge main into #5413 2026-10-31 20:45:41 +00:00
Claude 1fedaa1c5a Content mods: place mod features in biomes (biome modifiers)
A content mod's placed features now generate in vanilla's biomes through
NeoForge's biome modifiers (data/<ns>/neoforge/biome_modifier/, and
forge/biome_modifier/ for older mods): add_features, remove_features and
none. When the content root builds they rewrite the biomes' feature
lists, an added feature at the end of its step as NeoForge adds it; the
generator's FeatureSorter then orders them as vanilla does. Biomes no
modifier names keep their text, and a root with no modifiers keeps
vanilla's bundle. An unknown biome, tag, feature, step or type fails the
build with the mod and file named.

Part of #4953, closes #5431.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:45:38 +00:00
rdbtCVSandClaude Opus 5.5 dc1fc94a4c Merge main into #4945
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:40:44 +00:00
Claude 8a6eaac999 native_summon: return experience orbs' ids, and place passengers without a Pos
Follow-up to #5408. A summoned orb spawned but native_summon returned None,
since no command Target lookup covers orbs: return the stack's first
spawned id. Passengers without Pos were not placed and got lost: share
/summon's recursive place helper (place_summoned), which also supplies
the Fire and Rotation defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 20:39:58 +00:00
huiandZCode AI agent (GLM-5.3) 42e5a2fb8e Load conditions: an unknown kind nested in the tree leaves the file out
known() now checks the whole subtree, not only the immediate type:
not(not(unknown)) and or(true, not(unknown)) both failed to exclude
the file, because the outer combinator was a known kind and only the
direct child was checked. Both now fail closed, with tests.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
huiandZCode AI agent (GLM-5.3) bb0e421674 Recipes parse NeoForge's standard ingredient shapes
An ingredient that is one of NeoForge's standard object shapes now
resolves to the flat item list it matches, where the string parser
dropped the recipe file whole: neoforge:compound and
neoforge:intersection keep the items every sub-ingredient names,
neoforge:difference the base's without the subtracted's, and
neoforge:fluid a fluid's item form, its full bucket (a water or lava
tag names the same still fluid). A kind outside these still fails the
file - a mod's own ingredient types come with their own hook, not the
loader.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
TeiwazikandClaude Opus 5.5 d36550e82c Shader API: translucent terrain in the shadow pass, and its colour
Translucent terrain is drawn over a copy of the solid shadow map, so a pack can tell a point under
water or stained glass from one in shadow: shadow_map_translucent holds that depth, and shadow_color
the texture colour times the tint of the nearest such surface, with alpha 1 on a fluid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:32:52 +01:00
Aaron Wang 7bb5aec013 Measure a far teleport from where the entity is headed
setValuesFromPositionPacket measures tooBigToInterpolate from
PositionMoveRotation.of(entity), which is getClientPositionAndRotation:
the interpolation target while one runs. handleEntityPositionSync
measures from the entity's position. MovePacket keeps the two apart.

The mob and armor stand branches of move_entity now share stepped_move.
A mob put somewhere at once also resets its previous yaw and pitch
(snapTo's setOldPosAndRot), so it doesn't spin for a frame, and every
spawn starts with a fresh interpolation handler, a stand's included.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:25:51 -04:00
huiandZCode AI agent (GLM-5.3) 3379d00ae0 Load conditions: mod IDs, not namespaces; unknown fails closed; the overlay replaces before the gate
mod_loaded now asks which mods are loaded (the mod-content root's new
horizon-mod-ids.json marker), not which namespaces they own — a mod that
also owns a namespace doesn't make that namespace another mod. An
unknown condition kind fails the file through not and or as well, not
just on its own: `not` of an unknown kind is not a pass, and an `or`
over one doesn't short-circuit past it. And the stations, the recipe
book and brewing apply the world's packs first and gate the merged
file, as the crafting loader already did: a false-conditioned pack file
leaves the lower recipe gone too, not still active underneath.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:22:30 +01:00
Aaron Wang 34ff18cdad Armor stands turn between ticks and snap on far moves, as living entities
An armor stand is a LivingEntity, so a vanilla client moves it with the
SteppedInterpolationHandler over its update interval (3 ticks), position
and yaw together, and ArmorStand.tickHeadTurn has the body follow yRot.
Horizon eased stands with the generic three-step lerp and set the yaw at
once from each packet. Stands now share the mobs' stepped handler:
ArmorStand::interpolate takes its step, keeps the yaw continuous and
saves yBodyRotO, and the renderer and the F3+B hitbox turn the body by
Mth.rotLerp(partialTick, yBodyRotO, yBodyRot).

A move of more than 64 blocks now snaps (tooBigToInterpolate), with
nothing left to blend from (Entity.snapTo). TeleportEntity snaps too, as
setValuesFromPositionPacket does; before, only EntityPositionSync did,
so a far teleported mob also slid there over three ticks.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:06:36 -04:00
TeiwazikandClaude Opus 5.5 acc8c21f4c Shader API: [shadow], a shadow map of the solid terrain
A pack asks for a shadow map in its manifest and the renderer draws the solid terrain the light
sees into it each frame, from the shadow camera's lists shaders/ packs already use. The material
and post stages read it as shadow_map with shadow_sampler and the prelude's shadow_position, and
the frame gains light_direction, has_shadow, shadow_view_proj, shadow_distance, shadow_distortion,
shadow_resolution and shadow_center_texel. A pack without [shadow] gets a 1x1 map that lights
everything. Translucent terrain and entities don't cast yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:03:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 e2b71fa692 The Debug world preset: a grid of every block state
DebugLevelSource: barriers at y 60 and the block states in registry order on
a grid at y 70, one every other block. A Debug world's blocks never change
(Level.setBlock fails; setblock, fill and clone report it), nothing ticks
(scheduled, random, natural spawning), the login packet says isDebug, and
level-type minecraft:debug_all_block_states creates it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:02:03 +01:00
Aaron Wang ccc34ef964 Draw armor stands between ticks, so a falling one moves smoothly
EntityRenderer draws an entity at Mth.lerp(partialTick, xo, x), but armor
stands were drawn at their tick position, so a falling stand jumped once
a tick (20 times a second) while boats, TNT and mobs slid. ArmorStand now
keeps its previous position: ArmorStand::interpolate saves it before each
interpolation step on a server's client, a stand riding a mob or the
player takes it from its vehicle's previous seat (as the mob riders do,
so it stays right while the level is frozen), and the in-frame tick
saves it too. The body, its light, the culling box, the name tag, the
flames and the F3+B hitbox all use the blended position.

Fixes #4416

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 15:39:13 -04:00
reservedbytes 742e4dc206 fix(mods): a canceled hit forgets its attackers on every damage path 2026-10-31 18:37:46 -01:00
Claude 6b5780b686 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake 2026-10-31 19:33:10 +00:00
Claude f58fe06685 Native hooks: finite keeps its own doc comment 2026-10-31 19:33:10 +00:00
Federico TaglianiandClaude Sonnet 5.5 b1e4937c9a A rabbit eats ripe carrots
Rabbit.RaidGardenGoal: a MoveToBlockGoal (0.7, 16) to carrots at their maximum
age on #supports_crops ground, which takes one age off them (or the crop when
it had none) and leaves the rabbit full for 40 ticks (moreCarrotTicks, saved
as MoreCarrotTicks, running down by 0 to 2 a tick).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:31:50 +01:00
sirrayiandDeepSeek Harness 7170948484 test(probe): the mob probe feeds without a love cause
`feed_animal` takes the player who feeds an animal into love since this
branch; the probe passes none, so `--examples` builds again.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:24:24 +00:00
MartipopsandClaude Opus 5.5 242065a84d Merge upstream main into feature/launcher-auth-status
Main's #5397 translated the same 17 strings into ja, pt-br and tr that dd722ee7a did, with
other wording. Git merges both without a conflict, which left each of those tables with 17
duplicate keys. Main's wording is kept and this branch's dropped, so the tables differ from
main only by this branch's 26 account strings and the key it removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 15:21:49 -04:00
rdbtCVSandClaude Opus 5.5 a209fab13e Native hooks: finite keeps its own doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:31 +00:00
Claude 1ec53c7a3f Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:14 +00:00
sirrayiandDeepSeek Harness e8a93aa87e fix(mobs): a fox's baby trusts the players who bred it
`Fox.FoxBreedGoal.breed` hands the breeding to the baby as trust, which needs
the player who fed each parent into love: the animals kept the love counter
but not its cause. `Animal.LoveCause` is now kept (`Extra.love_cause`, fed
from the player who hands over the food, saved and read), and
`fox::trust_breeders` gives the baby both players, once each.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:19:52 +00:00
sirrayiandDeepSeek Harness c5f11d1eb6 fix(mobs): foxes trust the players their list names
`Fox.getTrustedEntities` and `Fox.addTrustedEntity` were modelled as "no
trusted players yet": the save wrote an empty `Trusted` list, nothing read it
back, and the avoid-player goal had no trust test, so a fox fled the player
who spawned it and nobody could become trusted (two rows of #3512).

- `Extra.trusted` holds the two `DATA_TRUSTED_ID` slots; `fox::trusted_entities`,
  `add_trusted_entity` (first free slot, then replacing the second),
  `clear_trusted` and `trusts` are the entity's own methods by name.
- The save writes the real list (`Fox.TRUSTED_LIST_CODEC`: int arrays) and
  `mob_from_nbt` clears and rebuilds it, as `Fox.readAdditionalSaveData` does.
- `AVOID_PLAYERS` no longer flees a player the fox trusts, which is what the
  row meant by a bred fox still running from you; `AvoidEntity::player_when`
  takes the avoider so the predicate can ask. A fox spawned from a spawn egg
  trusts the player who used it (`Fox.onOffspringSpawnedFromEgg`).

Tested without prepared game data:
`mobs::fox::trust_tests::a_fox_trusts_two_players_and_the_third_replaces_the_second`.

Still missing from the row: the breeding hand-off (needs a saved love cause,
which the mobs do not keep) and `DefendTrustedTargetGoal`; both are in the
note of the map rows.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:18:19 +00:00
huiandZCode AI agent (GLM-5.3) 617466720a Recipes answer NeoForge's load conditions
A data file with neoforge:conditions loads only when every condition
holds, and is silently absent otherwise, as NeoForge's ICondition leaves
it out of the manager. The gate covers the recipe loaders - crafting's
three kinds and the mods' own types, the stations, brewing, and the
recipe book - and the packs' overlays over them; mod_loaded asks which
mods' data is in the root (minecraft, always, and the content mods
that are on), and neoforge:true, not, and and or combine. A condition
the game does not know leaves the file out: a future shape fails
closed. Ported modpacks already carry such recipes verbatim (the carry
step keeps them), so cross-mod recipes stop loading blind.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 20:16:20 +01:00
k3yhandClaude Opus 5.5 ad85e99600 Smoke where a bucket's water evaporates in the Nether (#5288)
BucketItem.emptyContents ends an evaporation with ServerLevel.sendParticles
(LARGE_SMOKE, the block's corner, 8, spread 1, speed 0, ALTERNATIVE).
Horizon played the hiss but sent no smoke for a player's bucket, and the
dispenser drew its own smoke one block too wide (nextDouble * 2 - 1 around
the corner instead of nextDouble from it) and sent none to other players.

evaporation_smoke does both: the window's puffs and the packet, now with
its randomization type (QueuedParticle.randomization). A client receiving
LevelParticles now honors ALTERNATIVE and ALTERNATIVE_WITH_SPEED, as
ClientPacketListener.handleParticleEvent does: positions spread over
[0, dist), speeds kept or scaled by nextDouble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:10:43 -05:00
Claude d356420e76 Merge main into feat/native-shader-params
Resolves the conflicts with the water reflection fields: Frame.mods now
follows reflection_resolution at offset 528.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRonhDhVY6koHuncJx
2026-10-31 19:10:34 +00:00
levzzz 120a5f41e3 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 00:04:08 +05:00
TobiasLogicandClaude Opus 5.5 181d5f5ed6 Shader packs: four more translation fixes (sampler names, const parameters, arrays of arrays)
- A sampler named like a function (Bloop's `uniform sampler2D texture;` read as
  `texture(texture, uv)`) is the function where it is called, and the sampler elsewhere.
- A local named like a sampler, or like a sampler parameter, is in scope only after its
  initializer (GLSL 4.60 4.2.2): `float depth = texture(depth, c).x` still samples the parameter
  `depth` (Potato's depth-aware blur).
- A parameter's `const` (`const in float x`), which naga's parser refuses, is dropped: it only
  promises the function doesn't write the parameter.
- naga's frontend nests arrays of arrays the other way round (`float x[3][2]` comes out as 2
  arrays of 3; also `float[3][2]`, struct members and parameters), so indexing reads the wrong
  element or fails validation. Two or more dimensions in a type are written reversed, so the
  frontend's reversal restores GL's order; accesses are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:03:20 +07:00
levzzz fefa788a34 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast
# Conflicts:
#	launcher/Cargo.lock
2026-10-31 23:58:48 +05:00
TeiwazikandClaude Opus 5.5 03829bb88e Mipmaps: dither a high-resolution cutout's small levels
Grass blades a few texels wide at 2048 average to about the same alpha everywhere by 64, and no scale of that alpha keeps the coverage, so the alpha test passes the whole quad or none of it. From 64 texels up, each level below the base takes the mean alpha through an ordered dither, about as many texels in as the base has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 19:58:40 +01:00
Federico TaglianiandClaude Sonnet 5.5 cb12c27a6e MinecartSpawner: a spawner minecart ticks its BaseSpawner where it stands
A new cart kind (spawner_minecart, made by /summon: there is no item) that
shows a spawner block, drops and is picked as a plain minecart, and saves and
loads the BaseSpawner fields. Its serverTick is the spawner block's, with
broadcastEvent as an entity event on the cart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:55:19 +01:00
levzzzandClaude Opus 5.5 e36ed13712 Launcher: GPUI Fast instead of GPUI
longbridge/gpui-fast: Zed's GPUI with retained views and cached scroll layers.
The API changes are mechanical: focus and blur take the App, the application
comes from gpui-fast-platform (wgpu on Linux), menus say whether they're
disabled, shadows whether they're inset. oo7 follows to 0.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:55:12 +05:00
RansomandClaude Opus 5.5 46598a2bc5 Native mods: native_play_local_sound plays a client mod's sound, later
A client helper that plays a sound event, vanilla's or the mod's own
from its assets' sounds.json, for this player only and up to a minute
later (SoundEngine.playDelayed), so a mod can time a blast's sound to
its shock wave. Audio's distance delay now goes through the same
play_after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 de92159b98 Native mods: native_spawn_falling_block throws a block from a server mod
A server mod's helper that throws a falling block at a velocity: it
flies, lands and places itself as sand does, or drops as an item, and
can hurt what it lands on as an anvil does. For explosion debris and
collapsing builds. Refused on a client, for another dimension, air, an
unloaded chunk, values that aren't finite, or past 1024 falling blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 39755f90ff Native mods and shader packs: native_shader_params hands a mod's pack eight values
A client hook asked once a frame for eight vec4s that every stage of a
Horizon shader pack reads as frame.mods, so a native mod can tell the
pack it bundles where and when something happened (an explosion's
centre and age for a cloud ray marched in a post pass). Values that
aren't finite read as 0; all are 0 when no mod sets them. The
reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 4983adec8b Native mods: native_camera_shake lets a client mod shake the camera
A client hook asked each frame for turns in degrees and an offset in
blocks, which add up across mods. Horizon scales the sum by Distortion
Effects (0% turns it off), bounds it to 10 degrees and half a block on
each axis, and multiplies it into the view after the hurt tilt and view
bobbing. It moves what the player sees, not where they aim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:33:03 +00:00
MartipopsandClaude Opus 5.5 dd722ee7a7 Launcher: main's new strings in Japanese, Brazilian Portuguese and Turkish; tr gets the accounts'
Main's 31c1212bf added 17 strings to seven tables but not to ja, pt-br or the new tr, which
fails the every-language test on main. They are translated here as in the PR to main that
fixes it, so the lines are the same. tr.json also gets this branch's 26 account strings and
drops "Microsoft account · signed in", as ja and pt-br did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:23:00 -04:00
Claude 71955880b7 Draw a head profile's texture override instead of its skin
A ResolvableProfile can carry a `texture` id (PlayerSkin.Patch body, a
ClientAsset.ResourceTexture). Vanilla draws a player head with that
resource-pack texture instead of the downloaded or default skin; Horizon
ignored it and drew the profile's default skin. Placed heads and head
items now read the patch, loading non-vanilla namespaces' textures from
the resource packs into a skin slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P1ZTfB3AfwKgGnv7UFKYVD
2026-10-31 18:22:56 +00:00
Ludovic Marie ee20fbd1a9 Merge branch 't3794' into t3801 2026-10-31 19:17:11 +01:00
Ludovic Marie 696c5709e7 Merge branch 't3777' into t3794 2026-10-31 19:17:06 +01:00
Martipops 80bd8b2aa2 Merge upstream main into feature/launcher-auth-status
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 14:17:01 -04:00
Ludovic Marie f0761a306d Merge remote-tracking branch 'origin/main' into t3777
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 19:13:05 +01:00
MartipopsandClaude Opus 5.5 695574db09 Launcher accounts: a refresh saves to the accounts file it is given; pt-BR and Japanese strings
refresh_account and renew_for_game_with take the accounts.json a renewed profile is saved to.
The commands pass the launcher's own (Accounts::path()); the tests pass one that is never
there, so they no longer read or write the developer's launcher data. A new test checks that
a refresh saves the services' newer profile and leaves an unchanged one unwritten.

Main added pt-br.json and ja.json after this branch's strings: both get its 26 account
strings, and lose "Microsoft account · signed in", which the code no longer asks for (the
other tables dropped it already).

With main merged: profile_heads' test uses Accent::VIOLET (main made Accent a hue), and
docs/launcher-commands.md is regenerated for main's `completions` command, which main had
not written into it (the reference test failed on main too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:08:21 -04:00
Martipops 8bbf67da6f Merge upstream main into feature/launcher-auth-status 2026-10-31 13:55:01 -04:00
ErikKalikasandClaude Opus 5.5 91ed167997 Native mods: keep the block entity draw helpers for mods (dead_code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:50:44 +02:00
ErikKalikas a8a17b12d0 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw 2026-10-31 19:42:38 +02:00
ErikKalikasandClaude Opus 5.5 4d9dbdb565 Native mods: draw mod block entities on the client (BlockEntityRenderer.submit)
native_block_entity_submit runs once a frame for each mod block entity in
view, as vanilla's block entity renderers do (64 blocks, frustum, chunk
fade-in), with a pose stack and helpers that add to the entity batch:
quads, model-element boxes baked as FaceBakery does, and block models
(submitBlockModel). Content mods' textures/entity join the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:38 +02:00
Ludovic Marie abb630c564 Merge main into #3825
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 18:41:25 +01:00
Ludovic MarieandClaude Opus 5.5 802a3fa668 Merge #3794's branch; a model match only where ids change and one player waits with it; P1 with controllers off takes no pad
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:37:57 +01:00
Ludovic MarieandClaude Opus 5.5 95122e4ff8 Merge #3777's branch; a pressed pad skips players with controllers off; say splitscreen has no rumble yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:40 +01:00
Ludovic MarieandClaude Opus 5.5 da45330894 Merge main; splitscreen seats follow the owner's Allow Cheats, a failed join leaves P1 playing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:05 +01:00
Ludovic MarieandClaude Opus 5.5 c7b8559015 Launcher accounts: status workers wait for the lock; a refresh saves the renewed profile
rdbtCVS's two review points on #4250:
- The launcher's own status workers (off the UI thread, showing "Waiting for
  account access…") take the lock without the 3 s bound, so a worker queued
  behind another account's renewal no longer shows a false "Could not check
  sign-in". account list and MCP keep the bounded wait.
- refresh_account saves a renewed profile (new name or skin) to accounts.json
  as launch() does, and the UI reloads accounts and profile heads when a
  status arrives and the saved list changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 d876fa1212 Merge main into #3825; splitscreen windows: the review's three fixes
- audio.rs: main's shared-output fields (the device tracker's, f80c0360d).
- A guest in Window 1 leaves its F11 to P1: the game's window follows P1's option.
- disable() shows Window 1 again and says so (window1_shown), so a later
  split into Window 2 hides it again.
- Clicks, key presses and the wheel from a window the focused player isn't
  in no longer reach that player; releases still pass, so no key sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:11:32 +01:00
Ludovic Marie d291364f56 Merge main into #4250 2026-10-31 18:08:06 +01:00
rdbtCVSandClaude Opus 5.5 71245c0aaf Merge origin/main into the far terrain server branch
lan.rs keeps both the encoder's mod update tag and the far terrain service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:03:00 +00:00
rdbtCVSandClaude Opus 5.5 2d79156569 Far terrain: the span-cap test trips the cap, the encoder refuses past MAX_BIOMES
The forged reply was rejected on its empty biome palette and out-of-range
spans before the cap; it now names one biome and keeps every span inside
HEIGHTS, and asserts the cap's error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:58:58 +00:00
rdbtCVSandClaude Opus 5.5 618660d072 Far terrain server: an unreadable request takes a token too, as the doc says
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:36:11 +00:00
rdbtCVSandClaude Opus 5.5 9ec5a13f6b Merge origin/main into launcher auth status
launcher/lang/*.json: keep both sides' new strings (main's text where a key is in both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:15:13 +00:00
rdbtCVSandClaude Opus 5.5 35f41e670c Merge origin/main into split/09-windows
Kept PadRouter's state_of with main's stick_axes, both Flags fields, and filled
main's audio device-tracking fields in Audio::share.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:09:57 +00:00
rdbtCVSandClaude Opus 5.5 d309aed786 Merge origin/main into split/07-controllers
Conflicts: integrated.rs keeps both Flags fields (split_players, load);
pad/hardware.rs keeps the PadRouter split and moves main's stick_axes
mapping into state_of. A shared Audio gets main's device-tracking fields
and leaves reopening the output to the game's own Audio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:06:04 +00:00
rdbtCVSandClaude Opus 5.5 59e106cf25 Merge origin/main into split/06-two-players
main's audio device tracking (#default_seen, devices_seen, device_ticks) added fields
the shared Audio built by share() now fills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:05:26 +00:00
Ludovic MarieandClaude Opus 5.5 f9030e2da9 Far terrain client: requests leave from the game thread, failed tiles are asked again
Review fixes (kacpherek on #4947):
- a refused, lost, timed-out or unreadable tile comes back as no columns,
  and Lod asks for it again 2 s later instead of keeping it empty;
- sampling threads queue their requests and the game thread sends them
  each tick with its other packets, so they no longer take the
  connection's writer; requests queued when the far terrain ends are
  dropped with their slots;
- docs and the e2e: horizon-lod-distance is off unless set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:10:17 +01:00
Ludovic MarieandClaude Opus 5.5 55ed606b37 Far terrain on servers: Horizon clients ask the server for it
The last step: a Horizon client of a dedicated server or of a LAN world
(not of its own server thread, which hands its generator over directly)
draws far terrain when the server offers it. Its far terrain's samplers
each send one tile request and wait for the reply, which the network
thread hands straight to them (lod_net::Inbox), keeping to the server's
limits (requests waiting, tiles a second) so a well-behaved client is never
refused. LOD Distance stops at the server's distance. A new dimension, a
reconfiguration or a disconnect cancels the waiting samplers at once.

tools/server_lod_e2e.py checks it on a real dedicated server: with
horizon-lod-distance=16, a client with LOD Distance 32 gets the server's
far terrain, capped at 16 chunks, and draws far tiles; with 0, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:07:15 +01:00
Ludovic MarieandClaude Opus 5.5 4b7fc5ca8c Far terrain server: replies on the game thread, charged refusals, caught panics
Review fixes (kacpherek, dimaniojk on #4946):
- the worker hands finished replies back to the game thread, which sends
  them before the tick's flush_all, so they keep their place among the
  game's packets;
- a slot frees when the reply leaves, and replies wait while the player's
  connection has more than 1 MiB pending;
- every request takes a token, refusals included; unreadable or over-rate
  requests are strikes, and 64 strikes disconnect the player;
- a panic while sampling drops that level's sampler and refuses the tile,
  so every job still gets a reply;
- only clients with the horizon brand (the ones offered it) are served;
- a level with no far terrain refuses instead of leaving the client waiting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:06:58 +01:00
Ludovic MarieandClaude Opus 5.5 557f0da93d Far terrain from a server: serve horizon:lod tiles
The second step: a dedicated server, or a LAN world, offers far terrain to
Horizon clients while they configure (vanilla clients hear nothing) and
answers their tile requests. `horizon-lod-distance` in server.properties
(Horizon's own key, 64 chunks by default, 0 off) bounds how far; each
player may have 8 tiles waiting and ask for 64 a second (a burst of 256),
and a tile past the distance, over the limits or of the wrong shape is
refused at once. One "Far terrain server" thread, started at the first
request and behind the ticks (horizon_threads::behind_server), samples
them from the player's level for every player in turn, and sends each
reply to its player itself. Clients don't ask yet (the next step).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 fc9cf63829 Far terrain wire: off by default, versioned offer, bounded replies
horizon-lod-distance now defaults to 0: far tiles reveal the generator's
terrain beyond view distance, which helps crack a seed. The offer starts
with a protocol version. A reply over 512 KiB or 16 384 spans, with spans
out of order, overlapping, empty or out of the height range, is an error
before it allocates; the server refuses such a tile instead of sending it.
(Real tiles measure 4-6 spans a column.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:00:41 +01:00
Ludovic MarieandClaude Opus 5.5 8a7bc780ed Far terrain from a server: the horizon:lod wire format
The first of three steps that let a Horizon client get its far terrain
(LOD) from a dedicated server or a LAN world, whose generator and seed it
doesn't have: the offer a server sends Horizon clients in configuration,
the tile request, the reply (columns with biomes by name, block states by
id, or a refusal), the per-player check of a request's distance and shape,
the token bucket both sides pace with, and the `horizon-lod-distance`
property's reading (64 chunks by default, 1024 at most, 0 off). Nothing
sends or reads them yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:54:50 +01:00
some-du6eandClaude Opus 5.5 9a4e65ef6e Test that far terrain past the render distance loads fine first
Right past the render distance, far terrain showed as big flat-topped
boxes with grey stone sides: coarse ancestors standing in while the
sampling order filled a ring ahead first and refined broad tiles every
other job. Main now samples only the plan's leaves, nearest first; this
pulls that order into `sample_order` (no change) and tests it:

- At render distance 12, cells are 1 block wide to 256 blocks out and
  2 to 512 (Reduced: 1 to the near terrain's edge, 2 to 256).
- Loading one job at a time, the ring within two chunks of the near
  terrain reaches that detail all around after 270-301 jobs, all fine
  tiles (7500-8500 density lattice points). The old order took
  54000-119000 for the part in view alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:53:01 -06:00
martipops 9e3aaa3400 Merge branch 'split/08-join-with-controller' into split/09-windows
# Conflicts:
#	crates/horizon-client/src/game/options.rs
2026-10-30 19:04:33 -04:00
martipops 1e13f55868 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 19:03:22 -04:00
martipops 3779a7c5d0 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 c6661cfe72 Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 626de8923b Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:59:08 -04:00
martipops cd48c77ff2 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:37:16 -04:00
martipops c7dbf8f290 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 18:37:16 -04:00
martipops 858e8b3ba8 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:37:16 -04:00
martipops a2fcdeef3b Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:37:16 -04:00
martipopsandClaude Opus 5.5 a12892258f Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:45 -04:00
martipopsandClaude Opus 5.5 dbb23c1eca Splitscreen windows: don't retry a window that failed to open, pace on a shown one
- A window that failed to open was tried again every frame, each time
  re-laying out every player. Its players now play in Window 1 and the
  window is forgotten.
- Vsync and the frame pacing's refresh rate follow the first shown
  window, not a hidden Window 1; Window 1's visibility is kept in a
  field, so Wayland (which can't report it) isn't told every frame.
- Only free windows remember where they were: a monitor's rect made a
  later free window with its number open monitor-sized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:50 -04:00
martipops fd4b82f92d Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:18:16 -04:00
martipopsandClaude Opus 5.5 2d9d1f2264 Splitscreen: keep a lone P1's controller output between frames
route_pads released P1's pad hardware every frame, so with P1 alone the
reader lent for P1's turn lost its rumble and DualSense link a frame
after they started. It now releases only when nothing is lent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 2317bebacf Merge split/07-controllers into split/08-join-with-controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 aad4fd03f4 Splitscreen: hold back a trigger press that gave a controller its player
A controller first pressed with a trigger got a player, and the held
trigger then attacked or used: only buttons were held back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:30 -04:00
martipops a7802c004c Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:14:30 -04:00
martipops 3d94aa69d1 Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 7215229f71 Splitscreen: let a seat's name in only through memory
A LAN player who logged in under a registered seat's name (P2) passed
the integrated server's gate and the full-server check and held the
owner's permissions given to that seat. The gate now admits a seat's
name only from the memory connection (MEMORY_ADDRESS, port 0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 9945e72c81 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:33 -04:00
martipopsandClaude Opus 5.5 1af012a921 Launcher auth: let a status read give up behind a long operation
A refresh or a launch check holds the account lock across network calls.
Status reads (account list, the account page) now wait at most 3 s for
it, then say another operation is running, instead of waiting out a
network timeout. Writers still wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:38:37 -04:00
martipopsandClaude Opus 5.5 134b6f1a42 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:02:13 -04:00
martipopsandClaude Opus 5.5 af5ac52ace Launcher auth: answer review nits
Translate the account status messages, print a failed `account refresh`
once, fetch a profile head again a minute after its skin download failed,
and check every account's sign-in at most once a minute on page switches
(an account added meanwhile at once), so switching pages no longer reads
the keychain each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:57:48 -04:00
martipopsandClaude Opus 5.5 dc6d372db8 Let a running game renew its session through the launcher CLI
`account session WHO` renews an account and hands the running game a new
read-once session file, or the public status when it needs a browser
sign-in or a retry. `account sign-in --for WHO` runs the UUID-bound
re-sign-in from the command line. The launcher passes its own path to the
game as HORIZON_LAUNCHER_EXE. `account session` is kept out of MCP: its
file holds a token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:01:40 -04:00
0dab1469ac Show profile heads, auth progress, and targeted re-sign-in in the launcher
Fetch public skin heads on workers with a bounded, URL-keyed cache and UUID default fallback, report each account operation's actual phase, and bind Sign in again to the account's UUID so a different Microsoft account is rejected before any credentials are stored.

Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:34:27 -04:00
martipopsandCodex 3fab32c08a Give launcher account states semantic colors and icons
Show ready, refreshable, and sign-in-required states using the theme's success, warning, and danger colors, retaining icons and full labels.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:58:31 -04:00
martipopsandCodex 501c9abbfe Handle completed sign-in only once while polling the launcher
Compare sign-in states directly instead of comparing a state to an Option's debug output, which continually restarted every account's status checks.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:49:01 -04:00
martipopsandCodex f0a06742ea Show account status and refresh actions in the launcher
Inspect and renew due sessions on workers, expose Refresh and Sign in again actions, and share the operation through CLI and MCP for future launcher-to-game integration.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandCodex 6e1ac28831 Add launcher-owned account status and forced session renewal
Keep authentication state credential-free, remember rejected refresh credentials, preserve rotated credentials after downstream failures, and serialize refresh with sign-in, sign-out and launch.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandClaude Opus 5.5 f96acabfdc Splitscreen: the alone check in device_event is no surfaces, as in window_event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:46:46 -04:00
martipops 1a7e3da3c4 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:43:33 -04:00
martipops 5596fd89f4 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:43:33 -04:00
martipops f6bd4918d7 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:43:33 -04:00
martipops 38bda0fc71 Merge branch 'split/02-audio' into split/06-two-players 2026-10-29 23:42:35 -04:00
martipopsandClaude Opus 5.5 126d270448 Splitscreen: alone, device events go straight to P1 as window events do
Review: device_event turned P1's GUI scale on for every mouse motion even with no other
player; it now hands the event to P1 directly while there is no compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops 92ba57a02e Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:07:35 -04:00
martipops c350caa74f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:07:35 -04:00
martipops 91290d7d0a Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:07:35 -04:00
martipops 0286833cb7 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 23:07:34 -04:00
martipops 8a8ee31ea2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 21:39:41 -04:00
martipops c9e0a240a4 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 21:39:41 -04:00
martipops 69bc9623a1 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 21:39:40 -04:00
martipopsandClaude Opus 5.5 dc772c4601 Merge upstream main into part 6
Upstream moved the title actions into title_action (the split guest's Quit guard goes there),
added the server's online mode beside our memory-seat limit bypass, the audio output's device
name (a shared output carries it), and dropped code mods' key hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:16 -04:00
martipops 950e0aeac2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 13:39:25 -04:00
martipops ec514fd94b Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 13:39:25 -04:00
martipops 664c82127f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 13:39:25 -04:00
martipops 90f2db9114 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-29 13:39:15 -04:00
martipopsandClaude Opus 5.5 685548932b Splitscreen: players in windows of their own, or filling a monitor
Part 9 of the splitscreen series (#3541), on #3801. Each player plays in a window: Window 1
(the game's), Window 2, 3... opened for the players put there, or a borderless window filling
a monitor (over the menu bar on macOS, the system's borderless fullscreen elsewhere). Players
in one window share it. Closing a player's own window drops its players; the game's window
hides while nobody plays in it; titles say who plays where; a window reopens where it was.
Another player's F11 toggles its own window. HORIZON_SPLIT_WINDOWS (testing, until the Setup
screen) says each seat's window: `1,1,2`, or `m2` for monitor 2.

Live: tools/splitscreen_e2e.py adds `windows`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:01:59 -04:00
martipopsandClaude Opus 5.5 2edc3b57a4 Splitscreen: join with a controller; a lost controller's player waits
Part 8 of the splitscreen series (#3541), on #3794. While splitscreen watches the controllers
(HORIZON_SPLIT for now; the switch comes later), one reader serves every player, alone too:
the first controller used is P1's (its rumble and DualSense output lent to P1's turn). In P1's
world another controller pressing a button is offered to join on every view: A joins (as
P<seat>), Y drives P1 when P1 has none, B says no, 10 s and it goes. A controller that
disconnects leaves its player dimmed and asked: the same controller back takes it again, A on
another takes over, B lets it leave. Each view gets a short badge (who, and why: "Keyboard
and mouse" when it takes them).

Live: tools/splitscreen_e2e.py adds `alone` (watching, nobody joins).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:32:57 -04:00
martipops bd6ead73c3 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 12:24:01 -04:00
martipopsandClaude Opus 5.5 557a675d5d splitscreen_e2e: replies through a file, so Windows doesn't hang on launch
On Windows the game horizon-ctl launch starts inherits horizon-ctl's stdout pipe, which then
never closes while the game runs: subprocess.run waited forever. No pkill there either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:22:32 -04:00
martipops 408ca59a65 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 11:56:09 -04:00
martipopsandClaude Opus 5.5 c911e9060d splitscreen_e2e: find horizon-ctl.exe on Windows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:56:01 -04:00
martipopsandClaude Opus 5.5 9b19ab5b04 Splitscreen: each controller drives its own player
Part 7 of the splitscreen series (#3541), on #3777. While several play, one controller reader
(PadRouter) serves every player's game: a controller nobody has goes, when pressed, to the
first player without one (P1 first); its first press is not passed on; a controller that
disconnects leaves its player without one. Each player's glyphs follow its controller.
Alone, P1 reads the controllers itself, exactly as without splitscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:50:52 -04:00
martipopsandClaude Opus 5.5 70a9fd6f6a Splitscreen: a second player in the same window (HORIZON_SPLIT=N)
Part 6 of the splitscreen series (#3541), on parts 1 (compositor), 2 (audio), 4 (pointer
ownership) and 5 (layout). With HORIZON_SPLIT=N (testing only, no switch yet), N-1 more
players join P1's world through memory as P2, P3..., each its own Game in this process,
rendered offscreen and composited into P1's window. Keyboard and mouse go to the view
clicked last. A player who quits or disconnects drops out; alone again, P1 draws on its
window as before. Guests never open the controllers (pad.feed); routing comes next.

Live: tools/splitscreen_e2e.py (join, four).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:34:36 -04:00
martipops 563d85f957 Merge remote-tracking branch 'origin/split/05-viewports' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops ce0983fecc Merge remote-tracking branch 'origin/split/04-pointer-ownership' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops b3e5cb2fa6 Merge remote-tracking branch 'origin/split/02-audio' into split/06-two-players 2026-10-29 11:25:55 -04:00
607 changed files with 60727 additions and 5924 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: mojang republish
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
# gh workflow run "mojang republish" -f dry_run=true
on:
schedule:
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: mojang-republish
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
+1
View File
@@ -56,6 +56,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
Generated
+16
View File
@@ -1323,6 +1323,7 @@ dependencies = [
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
@@ -1334,6 +1335,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1391,6 +1395,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1475,6 +1480,7 @@ dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"log",
"serde",
"serde_json",
]
@@ -1506,6 +1512,15 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1609,6 +1624,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
+25
View File
@@ -66,3 +66,28 @@ with Microsoft accounts
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
## CIT Resewn
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
licensed under the MIT license:
> Copyright (c) 2021 SHsuperCM
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
+7
View File
@@ -281,6 +281,13 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
/// Not the server's player: a hit with extra knockback stops its sprint
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
pub sprinting: bool,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
@@ -39,6 +39,11 @@ pub enum Change {
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
+3
View File
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
# The narrator option's status and whether the narrator is there (Options.narrator).
horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
+38 -2
View File
@@ -96,6 +96,13 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -235,9 +242,34 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
if draws {
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
}
// A side's sprite, for rules that connect by tile: the first quad facing it.
let catalog = &models.catalog;
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
registry,
&mut sprites,
|state, face| {
let side = horizon_assets::model::Direction::ALL[face as usize];
catalog
.models_at(
state,
horizon_core::position::BlockPos::new(0, 0, 0),
&mut found,
)
.ok()?;
found
.iter()
.flat_map(|m| &m.faces)
.find(|f| f.quad.facing == side)
.map(|f| f.layer)
},
)
} else {
Default::default()
};
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -259,6 +291,10 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
+21 -2
View File
@@ -1179,6 +1179,15 @@ impl Audio {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
pub fn set_listener_reserve(&mut self, on: bool) {
self.engine.listener_reserve = if on {
crate::sound_engine::HEARD_RESERVE
} else {
0
};
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
@@ -1226,6 +1235,17 @@ impl Audio {
self.play(event, Some(at), volume, pitch);
return;
}
self.play_after(
event,
at,
volume,
pitch,
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
);
}
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
/// second) from now: `SoundEngine.playDelayed`.
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
@@ -1239,8 +1259,7 @@ impl Audio {
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
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.
+121
View File
@@ -387,6 +387,52 @@ impl Biomes {
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
@@ -534,6 +580,81 @@ mod pack_tests {
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
@@ -160,6 +160,18 @@ pub struct Brewing {
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
// absent. Vanilla's brewing recipes carry none; a pack's can.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
@@ -191,6 +203,11 @@ impl Brewing {
None => sources.push((id.to_owned(), v)),
}
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
// The data's recipe filters, over the final set.
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
+115 -4
View File
@@ -501,6 +501,19 @@ impl Camera {
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
}
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
/// First-person views do not call `adjust_distance`.
pub fn align_with_entity_distance(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
adjust_distance: impl FnOnce(f32) -> f32,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
@@ -528,10 +541,14 @@ impl Camera {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
let adjusted = adjust_distance(vanilla);
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
adjusted
} else {
vanilla
};
let zoom = self.max_zoom(distance, &mut clip);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
@@ -969,6 +986,100 @@ mod tests {
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn detached_distance_override_keeps_collision_clipping() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: Some(LivingCamera {
scale: 2.0,
camera_distance: 3.0,
sleeping: None,
dead: None,
}),
mount: Some((3.0, 4.0)),
};
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
let mut camera = Camera::default();
let mut calls = 0;
let mut rays = 0;
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
assert_eq!(distance, 12.0);
8.0
},
|_, _| None,
);
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
calls += 1;
assert_eq!(distance, 12.0);
8.0
},
|_, _| {
rays += 1;
Some([0.0, 0.0, z])
},
);
assert_eq!((calls, rays), (1, 8));
assert!((camera.position[2] - z).abs() < 0.2);
}
}
#[test]
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| distance,
|_, _| None,
);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
}
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| 0.0,
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::FirstPerson,
|_| panic!("first person has no detached distance"),
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
+50 -17
View File
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
/// and how far left of its place it starts (GUI pixels).
const INTRO_TICKS: f32 = 5.0;
const INTRO_WIDTH: f32 = 12.0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -233,14 +237,10 @@ impl Tag {
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
fn time_alpha(ticks: f32, added: u64) -> f32 {
let mut t = 1.0 - (ticks - added as f32) / 200.0;
t = (t * 10.0).clamp(0.0, 1.0);
t * t
}
#[derive(Default)]
pub struct Chat {
@@ -256,6 +256,11 @@ pub struct Chat {
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
/// The smooth chat extra: a new line slides in from the left while it
/// fades in, instead of appearing whole.
pub smooth: bool,
/// The smooth chat extra: no indicator bar at the left of a line.
pub hide_indicator: bool,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
@@ -555,7 +560,7 @@ impl Chat {
}
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
@@ -569,28 +574,47 @@ impl Chat {
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
// Vanilla's 200-tick fade reads the whole GUI ticks
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
let alpha_of = |line: &Line| {
if focused {
1.0
} else {
time_alpha(ticks, line.added)
time_alpha(fade_ticks, line.added)
}
};
// The smooth chat extra: how far into its intro a line is, 0 at the
// tick it was added and 1 once it has arrived, as (alpha, offset x).
let intro_of = |line: &Line| {
if focused || !self.smooth {
return (1.0, 0.0);
}
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t) * (1.0 - t);
(eased, (eased - 1.0) * INTRO_WIDTH)
};
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.map(|(i, line)| {
let (eased, slide) = intro_of(line);
(i, line, alpha_of(line) * eased, slide)
})
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
for &(i, _, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -598,9 +622,15 @@ impl Chat {
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
for &(i, line, alpha) in &lines {
for &(i, line, alpha, slide) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
if slide != 0.0 {
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
}
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
@@ -612,7 +642,7 @@ impl Chat {
shadow: true,
});
}
if let Some(tag) = &line.tag {
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
@@ -622,6 +652,9 @@ impl Chat {
color,
));
}
if slide != 0.0 {
out.push(GuiDraw::Pop);
}
}
if focused {
let count = lines.len() as i32;
@@ -658,7 +691,7 @@ impl Chat {
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
+56 -1
View File
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
}
}
#[test]
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
let mut chat = Chat {
smooth: true,
..Chat::default()
};
chat.lines.push(Line {
added: 0,
content: Text::literal("hi"),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Text { color, .. } => Some(color >> 24),
_ => None,
})
};
// The slide is the negative translate the line carries while it moves.
let slide = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
_ => None,
}),
_ => None,
})
};
// The tick it arrives: fully faded out, so nothing is drawn.
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
// Halfway through the intro: left of its place and still faint.
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
let faded = text_alpha(&halfway).expect("the line shows halfway through");
// Settled: in place and as opaque as vanilla's line.
let settled = chat.layout(361, INTRO_TICKS, false);
assert_eq!(slide(&settled), None);
assert!(text_alpha(&settled).expect("settled") > faded);
// The extra off draws the line whole on the tick it arrives, as vanilla.
chat.smooth = false;
let plain = chat.layout(361, 0.0, false);
assert_eq!(slide(&plain), None);
assert_eq!(text_alpha(&plain), text_alpha(&settled));
// The indicator bar is the -4 to -2 fill at the left of the line.
let bar = |draws: &[GuiDraw]| {
draws
.iter()
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
};
assert!(bar(&plain));
chat.hide_indicator = true;
assert!(!bar(&chat.layout(361, 0.0, false)));
}
#[test]
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
let font = font();
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
for scroll in [0, 3, 12] {
chat.scroll = scroll;
let mut links = 0;
for draw in chat.layout(361, 0, true) {
for draw in chat.layout(361, 0.0, true) {
if let GuiDraw::Text { text, x, y, .. } = draw {
let mut at = x as f64;
for (c, style) in text.chars() {
@@ -233,6 +233,9 @@ pub struct SkullSkin {
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
/// texture drawn instead of any skin.
pub texture: Option<String>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
+662
View File
@@ -0,0 +1,662 @@
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
//! `horizon_optifine::cit`'s.
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
use horizon_items::items::ItemStack;
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
use horizon_optifine::{Properties, properties_files_in};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
/// The pack's global `cit.properties` is not a rule.
pub fn load_rules(root: &Path) -> Vec<Rule> {
let tops = ["optifine", "mcpatcher", "citresewn"];
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
rule
})
.collect();
rules.sort_by(Rule::priority);
rules
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
any_item: Vec<u32>,
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
}
#[derive(Debug, Default)]
struct Prepared {
snbt: Option<Tag>,
json: Option<String>,
}
/// Cache entries kept before the cache starts over.
const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
pub fn new(rules: Vec<Rule>) -> Self {
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
let mut any_item = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let i = i as u32;
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
any_item.push(i);
}
for item in &rule.items {
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
let literals = rules
.iter()
.map(|r| r.components.iter().map(prepare).collect())
.collect();
Self {
rules,
by_item,
any_item,
literals,
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
kind: Kind,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
) -> Option<usize> {
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
let comps = encode_components(stack);
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
if let Some(hit) = lock(&self.selected).get(&key) {
return hit.map(|i| i as usize);
}
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
if named.is_empty() && self.any_item.is_empty() {
return Vec::new();
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
);
let hit = lock(&self.enchanted).get(&key).cloned();
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
return false;
}
// CIT Resewn: only damageable items have damage.
let damage = if max_damage > 0 { stack.damage } else { 0 };
if rule
.damage
.as_ref()
.is_some_and(|d| !d.matches(damage, max_damage))
{
return false;
}
let enchantments = stack.crafting_enchantments();
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
return false;
}
if let Some(levels) = &rule.enchantment_levels {
let level_in_range = enchantments.iter().any(|(id, level)| {
(rule.enchantments.is_empty() || listed(id))
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
});
if !level_in_range {
return false;
}
}
let literals = &self.literals[i as usize];
rule.components
.iter()
.zip(literals)
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
let mut cache = lock(cache);
if cache.len() >= CACHE_LIMIT {
cache.clear();
}
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
(stack.count, stack.damage, max, comps).hash(&mut h);
h.finish()
}
fn prepare(test: &ComponentTest) -> Prepared {
let ValueTest::Literal(lit) = &test.value else {
return Prepared::default();
};
let text = lit.text.trim();
let snbt = match parse_snbt(text) {
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
_ => None,
};
let json = json_plain(text);
Prepared { snbt, json }
}
/// The plain text of `s` read as a JSON text component.
fn json_plain(s: &str) -> Option<String> {
if !s.starts_with(['{', '[', '"']) {
return None;
}
let value: serde_json::Value = serde_json::from_str(s).ok()?;
Some(comp_text(&value).plain())
}
/// Whether one test passes, before its `negate`.
fn component_test(
test: &ComponentTest,
lit: &Prepared,
stack: &ItemStack,
comps: Option<&Tag>,
) -> bool {
let legacy;
let (root, text_depth) = match &test.source {
Source::Component(id) => {
let depth = match id.as_str() {
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
"minecraft:lore" => Some(1),
_ => None,
};
(comps.and_then(|c| c.get(id)), depth)
}
Source::LegacyEnchantments { stored } => {
let levels = if *stored {
stack.stored_enchantments()
} else {
stack.enchantments()
};
legacy = Tag::List(
levels
.iter()
.map(|(id, lvl)| {
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
Tag::compound([
("id", Tag::String((&**id).into())),
("lvl", Tag::Short(lvl)),
])
})
.collect(),
);
(Some(&legacy), None)
}
};
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
let defaulted;
let root = match (&test.source, root, test.path.first()) {
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
if matches!(
(id.as_str(), field.as_str()),
("minecraft:written_book_content", "generation")
| ("minecraft:fireworks", "flight_duration")
) && !map.contains_key(&JavaString::from(field.as_str())) =>
{
let mut map = map.clone();
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
defaulted = Tag::Compound(map);
Some(&defaulted)
}
(_, root, _) => root,
};
let Some(root) = root else {
return matches!(test.value, ValueTest::Exists(false));
};
if let ValueTest::Exists(want) = test.value {
return walk(root, &test.path, 0, &mut |_, _| true) == want;
}
walk(root, &test.path, 0, &mut |tag, depth| {
value_test(&test.value, lit, tag, text_depth == Some(depth))
})
}
/// Visits what `path` reaches from `tag`, true once `visit` is.
fn walk(
tag: &Tag,
path: &[String],
depth: usize,
visit: &mut dyn FnMut(&Tag, usize) -> bool,
) -> bool {
let Some((seg, rest)) = path.split_first() else {
return visit(tag, depth);
};
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
let len = match tag {
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
Tag::List(v) => v.len(),
Tag::ByteArray(v) => v.len(),
Tag::IntArray(v) => v.len(),
Tag::LongArray(v) => v.len(),
_ => return false,
};
let element = |i: usize| match tag {
Tag::List(v) => Cow::Borrowed(&v[i]),
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
_ => Cow::Owned(Tag::End),
};
if seg == "count" {
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
}
if seg == "*" {
return (0..len).any(|i| next(&element(i)));
}
seg.parse::<usize>()
.ok()
.filter(|&i| i < len)
.is_some_and(|i| next(&element(i)))
}
fn int_value(tag: &Tag) -> Option<i64> {
match *tag {
Tag::Byte(v) => Some(v.into()),
Tag::Short(v) => Some(v.into()),
Tag::Int(v) => Some(v.into()),
Tag::Long(v) => Some(v),
_ => None,
}
}
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
fn decimal(tag: &Tag) -> Option<String> {
let s = match *tag {
Tag::Float(b) => f32::from_bits(b).to_string(),
Tag::Double(b) => f64::from_bits(b).to_string(),
_ => return int_value(tag).map(|v| v.to_string()),
};
Some(if s.contains(['.', 'e', 'N', 'i']) {
s
} else {
s + ".0"
})
}
/// `text` against a string, its JSON text, or the plain text of a text component.
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
match tag {
Tag::String(s) => {
let s = s.to_string_lossy();
text(&s)
|| (is_text && text(&name_text(tag).plain()))
|| json_plain(&s).is_some_and(|p| text(&p))
}
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
_ => false,
}
}
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
match test {
ValueTest::Exists(_) => true,
ValueTest::Range(r) => {
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
}
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
ValueTest::Text(name) => match decimal(tag) {
Some(n) => name.matches(&n),
None => text_matches(tag, is_text, |s| name.matches(s)),
},
ValueTest::Literal(l) => match tag {
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
Tag::Compound(_) | Tag::List(_)
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
{
true
}
_ => match int_value(tag) {
Some(v) => l.int == Some(v),
None => text_matches(tag, is_text, |s| {
s == l.text || lit.json.as_deref() == Some(s)
}),
},
},
}
}
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
/// `actual`.
fn contains(expected: &Tag, actual: &Tag) -> bool {
match (expected, actual) {
(Tag::Compound(e), Tag::Compound(a)) => e
.iter()
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
_ => expected == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
fn rules(files: &[(&str, &str)]) -> CitRules {
let mut rules: Vec<Rule> = files
.iter()
.map(|(stem, text)| {
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
let text = format!("texture=x\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
})
.collect();
rules.sort_by(Rule::priority);
CitRules::new(rules)
}
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
let path = &cit.rules[i?].path;
path.rsplit('/').next()?.strip_suffix(".properties")
}
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
}
fn new(id: &str) -> ItemStack {
ItemStack::new(&format!("minecraft:{id}"), 1)
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
let mut stack = new(id);
let component = format!("minecraft:{component}");
stack.data_mut().other.push((component, tag));
stack
}
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
let mut stack = new(id);
let levels = levels
.iter()
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
stack.data_mut().enchantments = levels.collect();
stack
}
#[test]
fn heavier_rules_win_and_kinds_stay_apart() {
let cit = rules(&[
("plain", "items=stick"),
("heavy", "items=stick\nweight=2\nstackSize=2-"),
("offhand", "items=stick\nweight=5\nhand=off"),
("armor", "type=armor\nitems=stick"),
("wings", "type=elytra"),
]);
assert_eq!(item(&cit, new("stick")), Some("plain"));
assert_eq!(
item(&cit, ItemStack::new("minecraft:stick", 3)),
Some("heavy")
);
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
assert_eq!(item(&cit, new("stone")), None);
assert_eq!(item(&cit, new("elytra")), None);
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
}
#[test]
fn names_match_by_their_plain_text() {
let cit = rules(&[
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
("exact", "items=stone\ncomponents.custom_name=My Sword"),
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
]);
let named = |id: &str, name: Tag| {
let mut stack = new(id);
stack.data_mut().custom_name = Some(name);
stack
};
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
assert_eq!(item(&cit, new("stick")), None);
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
}
#[test]
fn lore_lines_and_list_counts() {
let cit = rules(&[
(
"any_line",
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
),
("two_lines", "items=stone\ncomponents.lore.count=2"),
]);
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
let lore = Tag::List(vec![string("Common"), bold]);
assert_eq!(
item(&cit, with("stick", "lore", lore.clone())),
Some("any_line")
);
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
let one = Tag::List(vec![string("A legendary item")]);
assert_eq!(item(&cit, with("stone", "lore", one)), None);
}
#[test]
fn custom_data_existence_and_negation() {
let cit = rules(&[
(
"aote",
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
),
(
"not_hyp",
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
),
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
]);
let data = |id: &str| {
let attributes =
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
with("stick", "custom_data", attributes)
};
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
let mut named = data("HYP");
named.data_mut().custom_name = Some(string("Hyperion"));
assert_eq!(item(&cit, named), None);
}
#[test]
fn legacy_enchantment_lists_and_model_data() {
let cit = rules(&[
(
"sharp",
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
),
("cmd", "items=stone\nnbt.CustomModelData=3"),
(
"cmd_snbt",
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
),
]);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 5)])),
Some("sharp")
);
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
let model = |id: &str, f: f32| {
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
};
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
assert_eq!(item(&cit, model("stone", 3.5)), None);
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
assert_eq!(item(&cit, model("dirt", 2.0)), None);
}
#[test]
fn fields_left_out_at_zero_read_as_zero() {
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
let mut rocket = new("firework_rocket");
rocket.data_mut().fireworks = Some((0, Vec::new()));
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
rocket.data_mut().fireworks = Some((2, Vec::new()));
assert_eq!(item(&cit, rocket), None);
}
#[test]
fn damage_counts_only_for_damageable_items() {
let cit = rules(&[
("worn", "items=diamond_sword\ndamage=50-100%"),
("new", "items=stick\ndamage=0"),
]);
let damaged = |id: &str, damage: u32, max: u32| {
let mut stack = new(id);
stack.damage = damage;
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
};
// Half of 1561 is 780.5.
assert_eq!(damaged("diamond_sword", 780, 1561), None);
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
assert_eq!(damaged("stick", 5, 0), Some("new"));
}
#[test]
fn enchantment_levels_and_rules_for_any_item() {
let cit = rules(&[
(
"sharp3",
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
),
("any3", "items=stone\nenchantmentLevels=3-"),
("glint", "type=enchantment\nenchantments=sharpness"),
(
"sword_glint",
"type=enchantment\nitems=diamond_sword\nweight=2",
),
]);
let mixed = [("sharpness", 2), ("unbreaking", 3)];
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
assert_eq!(
item(&cit, enchanted("stick", &[("sharpness", 4)])),
Some("sharp3")
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
assert_eq!(
glints(enchanted("diamond_sword", &sharp)),
["sword_glint", "glint"]
);
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
assert!(glints(new("stick")).is_empty());
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
}
+10 -2
View File
@@ -1,7 +1,15 @@
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
pub const CLOUD_HEIGHT: f64 = 192.33;
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
pub fn has_color(alpha: f32) -> bool {
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
}
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
pub fn visible(mode: &str, alpha: f32) -> bool {
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
mode != "false" && has_color(alpha)
}
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
@@ -110,7 +118,7 @@ impl Input<'_> {
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
@@ -0,0 +1,724 @@
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
//! the short list of rules that can touch it; meshing a face then walks only those lists
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
//!
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
use crate::sprites::Sprites;
use horizon_optifine::ctm::{
self, Connect, Method, Orient, Rule, Tile,
connect::{self as geometry, Axes, Axis, Face},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, path::Path};
/// A tile as the mesher draws it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TileLayer {
Layer {
layer: u16,
/// The sprite has fractional alpha (drawn in the translucent pass).
translucent: bool,
/// Every texel is opaque (drawn without an alpha test).
opaque: bool,
},
/// Try the next rule.
Skip,
/// The face's own sprite.
Default,
}
/// What a face draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
/// Its own sprite.
Keep,
Tile(TileLayer),
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
}
#[derive(Clone, Copy, Default)]
struct StateInfo {
/// Index of the state's block (`connect=block`).
block: u32,
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
}
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
by_state: Vec<(u32, u32)>,
by_layer: Vec<(u32, u32)>,
lists: Vec<u16>,
states: Vec<StateInfo>,
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
/// when no rule connects by tile.
face_layers: Vec<[u16; 6]>,
}
/// `list` appended to `lists`, as its range there.
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
let start = lists.len() as u32;
lists.extend_from_slice(list);
(start, list.len() as u32)
}
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
/// itself (as [`Sprites::insert_file`] names a tile).
fn sprite_name(resource: &str) -> String {
let parts = resource
.strip_prefix("assets/")
.and_then(|p| p.split_once('/'))
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
match parts {
Some((ns, path)) => format!("{ns}:{path}"),
None => resource.to_owned(),
}
}
impl ConnectedTextures {
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
let loaded = rules
.into_iter()
.filter_map(|rule| {
let tiles = rule
.tiles
.iter()
.map(|tile| match tile {
Tile::Skip => Ok(TileLayer::Skip),
Tile::Default => Ok(TileLayer::Default),
Tile::Texture(path) => {
// A tile among the textures is that sprite, under its own name.
let layer = match sprite_name(path) {
name if name != *path => sprites.insert(root, &name)?,
_ => sprites.insert_file(root, path)?,
};
Ok(TileLayer::Layer {
layer: u16::try_from(layer)?,
translucent: sprites.translucent[layer as usize],
opaque: sprites.opaque[layer as usize],
})
}
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
}
}
})
.collect();
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
})
.collect();
}
out
}
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
/// registry skips), `layer` answering a sprite name's layer.
fn build<'a>(
rules: Vec<LoadedRule>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
layer: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut out = Self {
rules,
..Self::default()
};
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
}
if by_layer[l].last() != Some(&i) {
by_layer[l].push(i);
}
}
}
for m in &r.rule.match_blocks {
let list = by_block.entry(m.id.as_str()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
}
}
for list in by_layer {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
out.by_state.push((0, 0));
out.states.push(StateInfo::default());
continue;
};
let next = blocks.len() as u32;
let block = *blocks.entry(name).or_insert(next);
let prop = |k: &str| props.get(k).map(String::as_str);
out.states.push(StateInfo {
block,
axis: match prop("axis") {
Some("x") => Some(Axis::X),
Some("y") => Some(Axis::Y),
Some("z") => Some(Axis::Z),
_ => None,
},
upper: prop("half") == Some("upper"),
});
list.clear();
if let Some(candidates) = by_block.get(name) {
list.extend(
candidates
.iter()
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
out
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
pub fn pick<'b>(
&self,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
self.list(&self.by_layer, layer as usize),
);
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
// both (it is in both lists); one naming either needs that one.
loop {
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
(None, None) => return Pick::Keep,
(Some((&x, rest)), Some((&y, other))) if x == y => {
(by_state, by_layer) = (rest, other);
(x, true, true)
}
(Some((&x, rest)), Some((&y, _))) if x < y => {
by_state = rest;
(x, true, false)
}
(Some((&x, rest)), None) => {
by_state = rest;
(x, true, false)
}
(_, Some((&y, rest))) => {
by_layer = rest;
(y, false, true)
}
};
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
r: &LoadedRule,
state: u32,
layer: u16,
face: Face,
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
if rule.faces & face.bit() == 0
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
{
return None;
}
if let Some(biomes) = &rule.biomes
&& !biome().is_some_and(|b| biomes.matches(b))
{
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
}
Connect::State => other == state,
Connect::Tile => self
.face_layers
.get(other as usize)
.is_some_and(|f| f[face as usize] == layer),
}
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
let case = geometry::ctm_index(mask) as u8;
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
Some(&(_, t)) => t,
None => {
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
};
}
}
}
Method::Horizontal => geometry::horizontal(mask()),
Method::Vertical => geometry::vertical(mask()),
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
Method::Top => {
// Only sides join the block above: the top and bottom go on to the next rule.
if matches!(face, Face::Up | Face::Down) {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
}
0
}
Method::Fixed => 0,
Method::Random => {
let face = geometry::symmetric_face(face, rule.symmetry);
let pos = if rule.linked && info.upper {
[pos[0], pos[1] - 1, pos[2]]
} else {
pos
};
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
}
Method::Repeat => {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
_ => return None,
};
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
/// `1000 * (r + 1) + i`.
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
let mut rules: Vec<Rule> = files
.iter()
.map(|(path, text)| {
Rule::parse(
&format!("assets/minecraft/optifine/ctm/{path}"),
&Properties::parse(text),
)
.unwrap()
.unwrap()
})
.collect();
ctm::sort(&mut rules);
rules
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
tiles: rule
.tiles
.iter()
.enumerate()
.map(|(i, t)| match t {
Tile::Skip => TileLayer::Skip,
Tile::Default => TileLayer::Default,
Tile::Texture(_) => TileLayer::Layer {
layer: (1000 * (n + 1) + i) as u16,
translucent: false,
opaque: false,
},
})
.collect(),
rule,
})
.collect()
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
let states = [
("minecraft:air", props(&[])),
("minecraft:glass", props(&[])),
("minecraft:stone", props(&[])),
("minecraft:oak_log", props(&[("axis", "y")])),
("minecraft:oak_log", props(&[("axis", "x")])),
("minecraft:oak_door", props(&[("half", "lower")])),
("minecraft:oak_door", props(&[("half", "upper")])),
];
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
)
}
fn tile(layer: u16) -> Pick {
Pick::Tile(TileLayer::Layer {
layer,
translucent: false,
opaque: false,
})
}
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
fn pick_in(
ctm: &ConnectedTextures,
state: u32,
layer: u16,
face: Face,
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
#[test]
fn glass_joins_its_neighbours() {
let ctm = world(&[("glass.properties", GLASS)]);
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1001)
);
// Glass all around in the face's plane: the middle tile, 26.
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
.collect();
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
// Stone beside it doesn't join; stone has no rule.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
}
#[test]
fn rule_order_faces_and_skips() {
let ctm = world(&[
(
"a_sides.properties",
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
),
("b_glass.properties", GLASS),
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
]);
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
// Sides: a_sides skips, so z draws.
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
}
#[test]
fn compact_quarters_and_overrides() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
)]);
let layer = |i: u16| TileLayer::Layer {
layer: 1000 + i,
translucent: false,
opaque: false,
};
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[]),
Pick::Quarters([layer(0); 4])
);
// Joined right only is case 1, replaced whole by tile 5.
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1005)
);
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
);
}
#[test]
fn logs_turn_with_their_axis() {
let ctm = world(&[(
"log.properties",
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
)]);
// An upright log with a log above: the bottom of a column.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// A log lying along x: on its north face the texture's up points east, so a log to the
// east (same block, other axis state) is above it.
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
tile(1003)
);
}
#[test]
fn top_and_connect_state() {
let ctm = world(&[(
"block_oak_log.properties",
"method=top\ntiles=0\nconnect=state",
)]);
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
tile(1000)
);
// The other axis is another state.
assert_eq!(
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
Pick::Keep
);
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
}
#[test]
fn linked_random_follows_the_lower_half() {
let ctm = world(&[(
"door.properties",
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
assert!(tiles.len() > 3, "the doors are not all alike");
}
#[test]
fn heights_and_biomes() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at =
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
}
#[test]
fn connect_by_tile_reads_the_neighbours_same_side() {
let mut ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
)]);
// Stone shows the glass sprite (3) on its north side; the door only on its south.
let mut faces = vec![[9u16; 6]; 7];
faces[1][Face::North as usize] = 3;
faces[2][Face::North as usize] = 3;
faces[5][Face::South as usize] = 3;
ctm.face_layers = faces;
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
tile(1000)
);
assert_eq!(
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
tile(1003)
);
}
#[test]
fn repeat_and_rows_then_columns() {
let ctm = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
assert_eq!(at([0, 63, 0]), tile(1002));
let hv = world(&[(
"glass.properties",
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
)]);
// Alone in its row, glass above: the column's bottom, tile 4.
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
tile(1004)
);
assert_eq!(
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
tile(1000)
);
}
#[test]
fn skips_defaults_and_narrowed_rules() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
),
(
"b.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
),
(
"c.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
),
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
(
"e.properties",
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
),
]);
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
// glass itself, which stops d.
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
let both = world(&[(
"e.properties",
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
)]);
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
// `top` passes the top face on.
let top = world(&[
("block_glass.properties", "method=top\ntiles=0"),
(
"z.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
),
]);
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn sprite_names() {
assert_eq!(
sprite_name("assets/minecraft/textures/block/dirt.png"),
"minecraft:block/dirt"
);
assert_eq!(
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
"assets/minecraft/optifine/ctm/glass/0.png"
);
}
}
@@ -9,6 +9,59 @@ pub use crate::chunk::block_entity::{
use crate::items::ItemStack;
use std::collections::HashMap;
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
/// slots players only take from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModMenuType {
pub id: &'static str,
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
}
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 {
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,
});
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
/// the content mods' menu types, in registry order.
pub fn mod_menu_types() -> &'static [ModMenuType] {
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
}
/// the mod menu type named `id`, kept out of line: a vanilla menu never
/// looks one up.
#[cold]
#[inline(never)]
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
mod_menu_types().iter().find(|m| m.id == id)
}
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
/// vanilla's only. A Horizon client has the server's (the content identity).
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
brand == "horizon" || menu.starts_with("minecraft:")
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -196,6 +249,7 @@ impl SkullSkin {
Self {
download,
dynamic,
texture: crate::skins::profile_texture_nbt(profile),
fallback,
}
}
@@ -610,6 +664,15 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
/// a client that isn't Horizon's is sent vanilla's menu types only.
#[test]
fn a_mod_menu_type_reaches_horizon_clients_only() {
assert!(menu_reaches("horizon", "ruby:press"));
assert!(!menu_reaches("vanilla", "ruby:press"));
assert!(!menu_reaches("fabric", "ruby:press"));
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
+62 -1
View File
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -354,7 +374,8 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
);
crate::logging::flush();
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
@@ -386,6 +407,30 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
)
}
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
if REPORTED.swap(true, Ordering::SeqCst) {
return;
}
let report = Report {
time: Local::now(),
description: "RAM limit reached".into(),
error: message,
stack: String::new(),
thread: thread_name(side),
side,
details: system_details(side),
log: crate::logging::recent_lines(),
};
match report.save(home) {
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
}
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
@@ -636,6 +681,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+5 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -583,7 +583,7 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
@@ -591,7 +591,8 @@ fn content_id(file: &str) -> Option<String> {
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -628,6 +629,7 @@ impl DataStore {
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"tags",
"trade_set",
"villager_trade",
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
@@ -141,6 +141,8 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
/// `visual/cloud_height`.
pub cloud_height: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -309,6 +311,10 @@ impl Environment {
.base
.get("minecraft:visual/cloud_fog_end_distance")
.map_or(2048.0, |v| v[0]),
cloud_height: self
.base
.get("minecraft:visual/cloud_height")
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
}
}
}
+96 -7
View File
@@ -96,8 +96,13 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Positional sounds leave a few static sound channels free for UI sounds
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
pub ui_sound_reserve: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// No particles from breaking or mining a block (other particles stay).
pub hide_block_particles: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -106,6 +111,10 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
pub chat_animation: bool,
/// The indicator bar at the left of a chat line (on by default, as vanilla).
pub chat_indicator: bool,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
@@ -113,6 +122,8 @@ pub struct Extras {
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,14 +149,19 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
ui_sound_reserve: false,
own_name_tag: false,
hide_block_particles: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
chat_animation: false,
chat_indicator: true,
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -213,14 +229,19 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +249,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -254,15 +275,20 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.ui_sound_reserve),
bool_text(self.own_name_tag),
bool_text(self.hide_block_particles),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
bool_text(self.chat_animation),
bool_text(self.chat_indicator),
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -383,16 +409,24 @@ pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
MouseTweaks,
MouseTweaksWheel,
Sort,
SortKey,
/// A new chat line slides in and fades in (`Chat::layout`).
ChatAnimation,
/// The coloured bar at the left of a chat line.
ChatIndicator,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -403,13 +437,20 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Ok(UiSoundReserve),
]
}
impl Item {
@@ -418,24 +459,32 @@ impl Item {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::ChatAnimation => "Smooth chat",
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
@@ -450,9 +499,15 @@ impl Item {
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::HideBlockParticles => {
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -465,12 +520,19 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::ChatAnimation => {
"A new chat line slides in from the left while it fades in, instead of appearing whole."
}
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -481,10 +543,15 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -494,13 +561,18 @@ impl Item {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::ChatAnimation => e.chat_animation,
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -521,13 +593,18 @@ impl Item {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -549,7 +626,8 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -575,11 +653,22 @@ mod tests {
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
Item::HideBlockParticles.cycle(&mut e, false);
assert!(e.hide_block_particles);
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
let only = Extras::parse("hideBlockParticles:true\n");
assert!(only.hide_block_particles && !only.own_name_tag);
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+3 -85
View File
@@ -1,85 +1,3 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
//! (the scheduled ticks use it too), and reachable here under its old path.
pub use horizon_core::fast_hash::*;
+77 -1
View File
@@ -112,6 +112,8 @@ struct Region {
cursor: (u32, u32),
row: u32,
pending: Vec<Patch>,
/// A glyph did not fit since the last `Font::begin_frame`.
full: bool,
}
struct Cache {
/// Every font by id, the default one first (the common lookup).
@@ -546,6 +548,7 @@ impl Region {
self.row = 0;
}
if y + h > region.1 {
self.full = true;
return None;
}
let (ax, ay) = (x, region.0 + y);
@@ -603,6 +606,7 @@ impl Font {
cursor: (0, 0),
row: 0,
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
@@ -669,8 +673,19 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
cache.region.full = false;
self.generation.set(next_generation());
}
/// Call once per frame before any text is laid out. When the dynamic
/// region ran out of room (vanilla's glyph textures grow instead), the
/// glyphs are forgotten so they are rasterized again from the start; it
/// waits for the frame boundary because quads already queued hold the old
/// rects.
pub fn begin_frame(&self) {
if self.cache.borrow().region.full {
self.reset_cache();
}
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
@@ -706,7 +721,16 @@ impl Font {
if let Some(g) = set.glyphs.get(&c) {
return *g;
}
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.glyphs.insert(c, glyph);
glyph
}
@@ -1418,4 +1442,56 @@ mod tests {
font.reset_cache();
assert_eq!(font.glyph('a').advance, 2.0);
}
#[test]
fn a_full_glyph_region_recovers_at_the_next_frame() {
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
let sheet = png(16, 8, |x, _| x % 8 < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"full",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
let a = font.glyph('a');
assert!(a.rect.is_some());
// No room for 'b': missing for now, and asked again next time.
assert!(font.glyph('b').rect.is_none());
assert!(font.glyph('b').rect.is_none());
assert_eq!(font.epoch(), epoch);
font.begin_frame();
assert_ne!(font.epoch(), epoch);
let b = font.glyph('b');
assert!(b.rect.is_some());
assert_eq!(b.advance, a.advance);
// Nothing overflowed since: the next frame keeps the glyphs.
let epoch = font.epoch();
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
#[test]
fn a_character_without_a_provider_does_not_reset_the_region() {
let sheet = png(8, 8, |x, _| x < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"nofill",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
assert!(font.glyph('z').rect.is_none());
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
}
File diff suppressed because it is too large. Load diff
@@ -299,6 +299,7 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if self.collect(tick, active) == 0 {
return;
@@ -341,7 +342,7 @@ impl SpriteAnimator {
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
@@ -859,7 +860,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick, None);
animator.animate(&mut encoder, &mut uploads, tick, None, None);
writes.push(
animator
.sprites
+306 -18
View File
@@ -1,12 +1,21 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
//! Translucent quads can have an arena of their own (`Arena::translucent`),
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
pub const GRAIN: u64 = 256;
/// The largest page, in bytes.
/// The largest page of the section arena, and of arenas shaders bind whole
/// as storage, in bytes.
pub const PAGE_BYTES: u64 = 256 << 20;
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
@@ -118,7 +127,7 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -127,16 +136,54 @@ impl Arena {
} else {
wgpu::BufferUsages::INDEX
};
Self::with_usage(device, 4, capacity, usage)
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
}
/// Sorted indices of the translucent arena's quads: pages up to
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::INDEX;
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
page_bytes: u64,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
// Pages of `page_bytes`, or less where the device's buffers (or the
// storage bindings that read them whole) are smaller.
let limits = device.limits();
let mut bytes = limits.max_buffer_size.min(page_bytes);
if usage.contains(wgpu::BufferUsages::STORAGE) {
bytes = bytes.min(limits.max_storage_buffer_binding_size);
}
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
@@ -151,11 +198,16 @@ impl Arena {
video_memory_full: false,
}
}
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
/// A buffer of `capacity` vertices in video memory or, when that is full,
/// `spill` allows it and so does the device, in system memory: Vulkan
/// drivers fail an allocation rather than page like OpenGL ones, and
/// wgpu's default error handler panics. `None` when neither has room.
fn create(
&mut self,
device: &wgpu::Device,
capacity: u64,
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -167,9 +219,10 @@ impl Arena {
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
if !spill
|| !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
@@ -234,7 +287,7 @@ impl Arena {
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
let grown = self.grown(current, len);
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, encoder, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
@@ -250,9 +303,9 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None if capacity > len => (self.create(device, len, true)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
@@ -392,8 +445,17 @@ impl Arena {
}
}
}
/// The capacity the newest page grows to for a range of `len`: double, but
/// by at most `PAGE_BYTES` past that size, so a large page is never left
/// half empty (nor held twice during the copy) by hundreds of MiB.
fn grown(&self, current: u64, len: u64) -> u64 {
let step = current.min(PAGE_BYTES / self.stride);
(current + step).max(current + len).min(self.page_limit)
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
/// memory only: moving all of it to system memory would slow every frame
/// that draws it, so a new page takes the range instead (and may spill).
fn grow(
&mut self,
device: &wgpu::Device,
@@ -402,7 +464,8 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let spill = capacity * self.stride <= PAGE_BYTES;
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -474,6 +537,26 @@ mod tests {
.ok()
}
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.ok()
}
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
/// the adapter's own let large pages past 256 MiB.
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
let devices = [device(), device_at_limits()];
devices.into_iter().flatten().collect()
}
/// `Arena::alloc`, with the copies of a grow submitted at once.
fn alloc(
arena: &mut Arena,
@@ -588,6 +671,119 @@ mod tests {
}
}
/// The renderer's half of that compaction (`move_off_page`) moves what each
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
page,
start,
translucent_at,
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
for split in [true, false] {
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Page 0: two sections' grains and two free ones; page 1: one taken.
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
arena.release(0, rest, 2 * GRAIN);
// Split, a grain of solid quads each (the first's translucent ones, and
// all of a water section's, in the translucent arena); else a grain of
// solid and translucent quads, and one of translucent quads alone.
let mut sections = if split {
vec![
section((0, a), (0, 0), 64, 64),
section((0, 0), (0, GRAIN), 0, 64),
section((0, b), (0, 0), 64, 0),
]
} else {
vec![
section((0, a), (0, a + 128), 32, 32),
section((0, b), (0, b), 0, 64),
]
};
// A range's first and last vertex hold its section's number, and the
// first translucent one of a mixed range that plus 100.
let write = |(page, at): (u32, u64), value: usize| {
let bytes = (value as u32).to_le_bytes();
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
};
for (i, s) in sections.iter().enumerate() {
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
write((page, start), i);
write((page, start + len - 1), i);
if !split && s.solid > 0 && s.translucent > 0 {
write(s.translucent_at, 100 + i);
}
}
}
let before: Vec<_> = sections
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
before[i],
"split {split}"
);
continue;
};
assert_eq!(page, 1, "split {split}");
assert_eq!(word((page, start)), i as u32);
assert_eq!(word((page, start + len - 1)), i as u32);
if !split {
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
if s.solid > 0 {
assert_eq!(word(s.translucent_at), 100 + i as u32);
}
}
}
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
assert!(!arena.has_page(0), "split {split}");
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -630,6 +826,96 @@ mod tests {
assert!(arena.pages.is_empty());
}
/// Every arena's pages stay within its cap and the device's limits: the
/// whole-page storage bindings of flat draws and shader packs included.
#[test]
fn pages_stay_within_their_caps_and_the_device_limits() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let bytes = |a: &Arena| a.page_vertices() * a.stride;
for (device, _) in &devices {
let limits = device.limits();
for storage in [false, true] {
for arena in [
Arena::new(device, 24, GRAIN, storage),
Arena::indices(device, GRAIN, storage),
] {
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
let binding = limits.max_storage_buffer_binding_size;
assert!(!storage || bytes(&arena) <= binding);
}
}
for large in [
Arena::translucent(device, 24, GRAIN),
Arena::translucent_indices(device, GRAIN),
] {
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
}
}
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let mut exercised = false;
for (device, _) in &devices {
let limits = device.limits();
let large = limits.max_buffer_size > PAGE_BYTES;
exercised |= large;
for flat in [false, true] {
for split in [false, true] {
let case = format!("large {large}, flat {flat}, split {split}");
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
}
}
}
if !exercised {
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
}
}
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
#[test]
fn large_pages_grow_by_bounded_steps() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
// Exercise the policy past the test device's own buffer limit.
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let step = PAGE_BYTES / 24;
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
assert_eq!(arena.grown(step, GRAIN), 2 * step);
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
assert_eq!(
arena.grown(arena.page_limit - GRAIN, GRAIN),
arena.page_limit
);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -638,7 +924,9 @@ mod tests {
};
for mut arena in [
Arena::new(&device, 28, GRAIN, false),
Arena::translucent(&device, 24, GRAIN),
Arena::indices(&device, GRAIN, false),
Arena::translucent_indices(&device, GRAIN),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
+21 -14
View File
@@ -528,7 +528,11 @@ impl Cull {
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn test_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
@@ -539,6 +543,7 @@ impl Cull {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -580,23 +585,25 @@ impl Cull {
self.current = Some(self.next);
}
/// After the frame's submission: start reading its results back.
pub fn submitted(&mut self) {
let Some(i) = self.current.take() else {
return;
};
/// The frame's results to read back, started by calling it right after the frame's
/// submission (on the submit thread, when the frame goes there).
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let state = r.state.clone();
state.store(PENDING, Ordering::Release);
r.buffer
.slice(..r.slots.len() as u64 * 4)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
})
}
fn make_group(
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -79,6 +81,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -258,13 +258,20 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
let mut pass = begin(
encoder,
"Entity outline",
&t.outline,
clear,
timing.as_deref_mut(),
);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -280,7 +287,7 @@ impl EntityOutline {
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
@@ -293,6 +300,7 @@ fn begin<'e>(
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -305,6 +313,7 @@ fn begin<'e>(
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
})
+42 -5
View File
@@ -62,7 +62,13 @@ pub struct Call {
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -286,7 +292,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -297,6 +303,7 @@ impl Flat {
&wgpu::PipelineLayout,
&str,
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
@@ -354,11 +361,35 @@ impl Flat {
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"fs_solid",
None,
back,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"fs_opaque",
None,
back,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"fs_solid",
None,
back,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
@@ -366,6 +397,7 @@ impl Flat {
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"vs_terrain_flat",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
@@ -490,6 +522,7 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
@@ -668,7 +701,7 @@ impl Flat {
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
@@ -700,13 +733,17 @@ impl Flat {
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn flatten_phase2(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
@@ -1,9 +1,11 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
@@ -10,8 +10,8 @@
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
@@ -442,6 +445,7 @@ impl Interpolation {
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
@@ -644,10 +648,12 @@ mod tests {
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
@@ -682,6 +688,35 @@ mod tests {
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
@@ -731,6 +766,7 @@ mod tests {
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
@@ -776,7 +812,8 @@ mod tests {
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
@@ -795,7 +832,26 @@ mod tests {
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
@@ -24,7 +24,8 @@
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
@@ -85,6 +86,8 @@ struct Params {
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
+17 -4
View File
@@ -366,7 +366,11 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -387,6 +391,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
color_attachments: &attachments,
..Default::default()
});
@@ -397,7 +402,11 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -413,6 +422,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
color_attachments: &attachments,
..Default::default()
});
@@ -434,6 +444,7 @@ impl Hdr {
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
@@ -443,7 +454,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -494,7 +505,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
Some(texture)
}
@@ -528,6 +539,7 @@ impl Hdr {
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -541,6 +553,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
color_attachments: &attachments,
..Default::default()
});
+1 -1
View File
@@ -1,4 +1,4 @@
//! Far meshes share the terrain arena and passes, with their actual bounds
//! Far meshes share the terrain arenas and passes, with their actual bounds
//! rather than the 16³ boxes used by the near-section residency walk.
use super::{Frustum, Renderer, SectionGpu};
use crate::{
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -79,26 +87,89 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
/ f32(textureDimensions(horizon_pbr_normals).x);
let l = max(lod - log2(scale), 0.0);
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
}
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
}
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let s = horizon_terrain_sprite_sampler;
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
switch layer / horizon_terrain_SPRITE_SPLIT {
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
}
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the texture: its texels, as the sampler would pick.
let texels = in.uv * horizon_block_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -115,6 +186,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -123,6 +204,8 @@ pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
pub writes_surface: bool,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
@@ -134,12 +217,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -150,18 +235,28 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -174,14 +269,17 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -220,6 +318,18 @@ impl NativeMaterial {
constants: &constants,
..Default::default()
};
let solid_targets = [
Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
@@ -245,11 +355,11 @@ impl NativeMaterial {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
&solid_targets
} else {
&solid_targets[..1]
},
}),
multiview_mask: None,
cache: None,
@@ -294,6 +404,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -346,14 +457,20 @@ mod tests {
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -13,6 +14,13 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -24,12 +32,24 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
@@ -43,6 +63,34 @@ impl NativePack {
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -55,10 +103,10 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -67,6 +115,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -75,6 +124,11 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
Ok((pack, labels))
@@ -82,6 +136,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
@@ -14,16 +14,74 @@ use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
enum Writes {
/// One of the chain's two targets.
Chain(usize),
/// A pack target (`[[post]] output`).
Target(usize),
/// The screen: the last pass.
Screen,
}
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
/// none).
#[derive(Clone, Copy)]
struct PassPlan {
writes: Writes,
reads: Option<usize>,
}
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
/// output writes that and reads the chain's picture, which goes on past it.
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
let mut current = None;
outputs
.iter()
.enumerate()
.map(|(i, output)| {
let reads = current;
let writes = match output {
_ if i + 1 == outputs.len() => Writes::Screen,
Some(t) => Writes::Target(*t),
None => {
let slot = usize::from(current == Some(0));
current = Some(slot);
Writes::Chain(slot)
}
};
PassPlan { writes, reads }
})
.collect()
}
/// A pack target's size: `scale` of the world's `size`, at least a texel.
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
}
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
/// The pack's textures.
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
/// and its colour (bindings 9 and 10).
shadow: (
wgpu::TextureView,
wgpu::Sampler,
wgpu::TextureView,
wgpu::TextureView,
),
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
@@ -32,6 +90,12 @@ pub struct NativePost {
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// What each pass writes and reads (`[[post]] output`).
plan: Vec<PassPlan>,
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
/// size.
scales: Vec<f32>,
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -39,9 +103,16 @@ pub struct NativePost {
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
/// what the material stage reads besides.
pub struct NativeFrame {
/// The solid terrain's second target, the post stage's `scene_surface`.
surface: wgpu::Texture,
pub surface_view: wgpu::TextureView,
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
/// The pack's `[[textures]]` and their samplers, bound for every stage.
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
pub block_light: super::block_light::BlockLight,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
@@ -49,11 +120,17 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -140,23 +217,64 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries = vec![
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
pbr_entry(12),
pbr_entry(13),
];
let block_light_entries = [
wgpu::BindGroupLayoutEntry {
binding: 9,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 10,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
entries.extend(block_light_entries);
entries.extend(pack_texture_entries(textures.len()));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &entries,
});
let surface = surface_target(device, [1, 1]);
let surface_view = surface.create_view(&Default::default());
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
let blank = copy_target(
@@ -172,23 +290,62 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
})
};
let pbr = match pbr {
Some(maps) => maps.each_ref().map(array_view),
None => {
// Two layers: one would make a 2D texture on the OpenGL backend.
let blank = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack LabPBR (none)"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
[array_view(&blank), array_view(&blank)]
}
};
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
&shadow_map,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
blank,
reflection_sampler,
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
last: None,
eye: [f32::NAN; 4],
@@ -199,43 +356,93 @@ impl NativeFrame {
}
}
/// What the shadow camera sees this frame, for the renderer's shadow lists.
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
let camera = self.shadow_camera.as_ref()?;
Some(super::pack_post::ShadowView {
cull: super::pack_uniforms::shadow_culling(frame, camera),
light: super::pack_uniforms::shadow_light(frame, camera),
// The light's box culls: on the ground it reaches further than `distance` along
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
})
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
shadow: &super::native_shadow::ShadowMap,
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
textures: &[(wgpu::TextureView, wgpu::Sampler)],
block_light: &super::block_light::BlockLight,
pbr: &[wgpu::TextureView; 2],
) -> wgpu::BindGroup {
let opaque_depth = opaque_depth.create_view(&Default::default());
let opaque_color = opaque_color.create_view(&Default::default());
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&opaque_depth),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(&opaque_color),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&pbr[0]),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::TextureView(&pbr[1]),
},
];
entries.extend([
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&block_light.view),
},
wgpu::BindGroupEntry {
binding: 10,
resource: block_light.grid.as_entire_binding(),
},
]);
entries.extend(pack_texture_group_entries(textures));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -268,6 +475,14 @@ impl NativeFrame {
self.regroup(device);
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
self.surface = surface_target(device, size);
self.surface_view = self.surface.create_view(&Default::default());
}
}
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
/// before the stages read it.
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
@@ -284,8 +499,11 @@ impl NativeFrame {
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
&self.shadow_map,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -295,7 +513,13 @@ impl NativeFrame {
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
@@ -364,6 +588,24 @@ pub fn present_constants<'a>(
.collect()
}
/// The solid terrain's surface target of `size`, drawn and read.
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack surface"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::native_material::SURFACE_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -377,6 +619,29 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -392,22 +657,44 @@ impl NativePost {
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
manifest: &horizon_shader_api::Manifest,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
.post
.iter()
.map(|p| {
let name = p.output.as_deref()?;
targets.iter().position(|t| t.0 == name)
})
.collect();
let plan = plan(&outputs);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut entries = vec![
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -446,7 +733,11 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
format: if plan[i].writes == Writes::Screen {
format
} else {
INTERMEDIATE
},
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -470,7 +761,14 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
frame.shadow_map.sampler.clone(),
frame.shadow_map.translucent_view.clone(),
frame.shadow_map.color_view.clone(),
),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
@@ -478,6 +776,9 @@ impl NativePost {
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
scales: targets.iter().map(|t| t.1).collect(),
named: None,
plan,
format,
})
}
@@ -490,11 +791,43 @@ impl NativePost {
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
.iter()
.map(|&scale| {
let size = scaled(size, scale);
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack target"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
})
.collect();
self.named = Some((size, views));
self.groups = None;
}
let chain = self
.plan
.iter()
.any(|p| matches!(p.writes, Writes::Chain(_)));
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -531,8 +864,11 @@ impl NativePost {
if self
.groups
.as_ref()
.is_some_and(|((color, depth), blank, _)| {
color == world.0 && depth == world.1 && *blank == blank_history
.is_some_and(|((color, depth, surface), blank, _)| {
color == world.0
&& depth == world.1
&& surface == world.2
&& *blank == blank_history
})
{
return;
@@ -545,50 +881,91 @@ impl NativePost {
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
entries: &entries,
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
groups,
));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -597,7 +974,7 @@ impl NativePost {
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
history_reset: bool,
@@ -611,8 +988,9 @@ impl NativePost {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -634,14 +1012,17 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
.map(|p| p.writes)
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
targets[slot].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
@@ -676,7 +1057,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
pub fn frame_inputs(
frame: &Frame,
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
@@ -748,11 +1135,147 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
light_direction: match shadow {
Some(s) => super::pack_uniforms::shadow_light(frame, s),
None => Vec3::Y,
}
.to_array(),
has_shadow: u32::from(shadow.is_some()),
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
super::pack_uniforms::shadow_culling(frame, s)
})),
shadow_distance: shadow.map_or(0.0, |s| s.distance),
shadow_distortion: shadow.map_or(0.0, |s| {
super::native_shadow::distortion(s.distance, s.resolution)
}),
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
shadow_center_texel: shadow.map_or(0.0, |s| {
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
}
}
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
pub fn pack_textures(
device: &wgpu::Device,
queue: &wgpu::Queue,
report: &horizon_shader_api::Report,
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
report
.textures
.iter()
.map(|(t, image)| {
let texture = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: Some(&t.id),
size: wgpu::Extent3d {
width: image.width,
height: image.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,
&image.rgba,
);
let filter = if t.linear {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
};
let address = if t.repeat {
wgpu::AddressMode::Repeat
} else {
wgpu::AddressMode::ClampToEdge
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&t.id),
address_mode_u: address,
address_mode_v: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
});
(texture.create_view(&Default::default()), sampler)
})
.collect()
}
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_SAMPLER_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
]
})
.collect()
}
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
.flat_map(|(i, (view, sampler))| {
[
wgpu::BindGroupEntry {
binding: TEXTURE_BINDING + i as u32,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: TEXTURE_SAMPLER_BINDING + i as u32,
resource: wgpu::BindingResource::Sampler(sampler),
},
]
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
use super::{Writes, plan};
// Bloom into a target, a pass of the chain, a second target, the picture.
let p = plan(&[Some(0), None, Some(1), None, None]);
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
assert!(writes[0] == Writes::Target(0));
assert!(writes[1] == Writes::Chain(0));
assert!(writes[2] == Writes::Target(1));
assert!(writes[3] == Writes::Chain(1));
assert!(writes[4] == Writes::Screen);
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
}
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
@@ -814,7 +1337,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -823,6 +1346,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -870,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -933,8 +1457,16 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
@@ -969,7 +1501,7 @@ file = \"b.wgsl\"
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
history_reset,
@@ -6,6 +6,19 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
/// The game was built with native mods (the game sets it at start): one of them may draw in
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
pub fn set_mods_built_in(on: bool) {
MODS_BUILT_IN.store(on, Ordering::Relaxed);
}
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
@@ -0,0 +1,673 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
/// cutout test (leaves' and plants' holes let the light through).
const ENTRIES: &str = "
struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let xy = shadow_distort(clip.xy);
var out: HorizonShadowOut;
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
distance: settings.distance,
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
..Shadow::default()
}
}
/// `settings` with the resolution `device` can make.
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
Settings {
resolution: settings
.resolution
.min(device.limits().max_texture_dimension_2d),
..settings.clone()
}
}
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
/// three times as fine as at its edge.
const SNAP: f32 = 8.0;
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
pub fn distortion(distance: f32, resolution: u32) -> f32 {
let texel = 2.0 * distance / resolution.max(1) as f32;
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
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::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
}
/// A depth pass into the map that clears it to "far".
pub fn begin<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
}
}
}
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
/// stages' own group 3 also holds the map being drawn).
pub struct NativeShadow {
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
)
}
impl NativeShadow {
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame_layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack shadow"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack shadow"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
// Both sides: the quads that face the light are drawn (`facing_runs`), and
// plants' crossed quads cast from either side.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
Ok(Self {
solid,
flat,
translucent,
flat_translucent,
entity,
group,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
&shadow,
);
// Up to the build height's span below a player flying high, as well as above them.
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
assert!(clip.is_finite(), "{clip}");
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
}
}
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
let centre = view.inverse().w_axis.truncate();
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
}
}
#[test]
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
// Already that fine: no distortion.
assert_eq!(distortion(32.0, 2048), 0.0);
assert_eq!(distortion(10_000.0, 1024), 0.95);
}
}
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
+15 -3
View File
@@ -620,6 +620,7 @@ impl Oit {
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
@@ -645,7 +646,7 @@ impl Oit {
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
@@ -680,6 +681,7 @@ impl Oit {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
@@ -692,6 +694,7 @@ impl Oit {
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
@@ -710,7 +713,7 @@ impl Oit {
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
@@ -720,16 +723,18 @@ impl Oit {
depth,
(label, group, &then),
(clouds, cloud_depth),
timing.as_deref_mut(),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -749,6 +754,7 @@ impl Oit {
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
#[allow(clippy::too_many_arguments)]
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -764,6 +770,7 @@ impl Oit {
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
@@ -774,6 +781,7 @@ impl Oit {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
@@ -798,6 +806,7 @@ impl Oit {
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(terrain) = &t.terrain_depth else {
return;
@@ -810,6 +819,7 @@ impl Oit {
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
@@ -840,6 +850,7 @@ impl Oit {
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
@@ -852,6 +863,7 @@ impl Oit {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
@@ -217,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -3117,8 +3117,7 @@ impl PackPost {
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
self.shadow_targets.as_ref()?;
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
if !self.has_shadow() {
return None;
}
let shadow = &self.uniforms.shadow;
@@ -3139,6 +3138,12 @@ impl PackPost {
self.terrain.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
pub fn has_shadow(&self) -> bool {
self.shadow_targets.is_some()
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
}
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
/// What the world's background is when the pack draws the sky: colortex0's clear colour
/// (`None`: the fog colour, its default).
@@ -1,5 +1,6 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
@@ -139,6 +140,12 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
}
}
/// The compute pass's twin of `stamps`.
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
wgpu::ComputePassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
@@ -35,6 +35,33 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
pub right_handed: bool,
pub on_ground: bool,
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
/// logical height, sky light and ceiling.
pub min_y: i32,
pub height: i32,
pub logical_height: i32,
pub has_skylight: bool,
pub has_ceiling: bool,
/// `isElytraFlying`: the player is fall flying.
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
/// has alpha (`LevelExtractor`); a dimension without clouds has no height, which the uniform
/// reports as NaN. NaN is not vanilla's own value: packs map it to "no clouds".
pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
if crate::clouds::has_color(cloud_color_alpha) {
height
} else {
f32::NAN
}
}
/// One uniform's value: up to 16 floats or 3 integers.
@@ -171,6 +198,15 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
"heightLimit",
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"isElytraFlying",
"cloudHeight",
];
/// Values carried from frame to frame.
@@ -245,7 +281,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
pub const SHADOW_EYE: f32 = 100.0;
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
@@ -264,7 +300,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(DVec3::splat(step))
} else {
camera % step
};
DVec3::splat(step / 2.0) - remainder
} else {
DVec3::ZERO
};
@@ -373,6 +414,13 @@ impl FrameUniforms {
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
"bedrockLevel" => Value::i(&[inputs.min_y]),
"heightLimit" => Value::i(&[inputs.height]),
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -432,6 +480,8 @@ impl FrameUniforms {
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
@@ -526,6 +576,25 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
assert_eq!(cloud_height(0.8, 120.0), 120.0);
assert!(cloud_height(0.0, 192.33).is_nan());
}
#[test]
fn elytra_and_cloud_height_uniforms_are_written() {
assert!(NAMES.ends_with(&["isElytraFlying", "cloudHeight"]));
let mut out = vec![0u8; 16];
Value::i(&[1]).write("bool", &mut out, 0);
assert_eq!(i32::from_ne_bytes(out[0..4].try_into().unwrap()), 1);
Value::f(&[192.33]).write("float", &mut out, 4);
assert_eq!(f32::from_ne_bytes(out[4..8].try_into().unwrap()), 192.33);
Value::f(&[f32::NAN]).write("float", &mut out, 8);
assert!(f32::from_ne_bytes(out[8..12].try_into().unwrap()).is_nan());
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
@@ -0,0 +1,228 @@
//! The frame's commands finished, submitted and presented on a thread of their own.
//!
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
//! would otherwise wait for before building the next frame. The renderer hands the
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
pub fn wanted() -> bool {
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => cfg!(target_os = "linux"),
}
}
/// Work to start right after a frame's submission (a readback's `map_async`).
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
/// A frame for the submit thread: its commands and, in a window, the surface to present.
pub struct Job {
pub encoder: wgpu::CommandEncoder,
pub surface: Option<wgpu::SurfaceTexture>,
pub after: Option<AfterSubmit>,
}
struct Thread {
jobs: Option<mpsc::Sender<Job>>,
handle: Option<std::thread::JoinHandle<()>>,
}
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
struct Landing {
done: mpsc::Receiver<wgpu::SubmissionIndex>,
landed: Option<wgpu::SubmissionIndex>,
}
/// The device's queue, with frames submitted on the submit thread when it runs.
pub struct Queue {
queue: wgpu::Queue,
thread: Option<Thread>,
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
}
impl Queue {
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
let mut this = Self {
queue,
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
};
if !threaded {
return this;
}
let (jobs, job_rx) = mpsc::channel::<Job>();
let (done_tx, done) = mpsc::channel();
let queue = this.queue.clone();
let spawned = std::thread::Builder::new()
.name("Render submit".into())
.spawn(move || {
for job in job_rx {
let commands = job.encoder.finish();
let submission = queue.submit([commands]);
if let Some(after) = job.after {
after();
}
if let Some(surface) = job.surface {
queue.present(surface);
}
if done_tx.send(submission).is_err() {
break;
}
}
});
match spawned {
Ok(handle) => {
this.thread = Some(Thread {
jobs: Some(jobs),
handle: Some(handle),
});
*this.lock() = Some(Landing { done, landed: None });
}
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
}
this
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
self.landing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether frames can go to the submit thread.
pub fn threaded(&self) -> bool {
self.thread.is_some()
}
/// Finish, submit and present the frame on the submit thread, then run its `after`;
/// given back, with nothing done, when there is none.
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
return Err(Box::new(job));
};
self.wait();
match jobs.send(job) {
Ok(()) => {
self.pending.store(true, Ordering::Release);
Ok(())
}
Err(mpsc::SendError(job)) => Err(Box::new(job)),
}
}
/// Wait until the frame on the submit thread is submitted and presented.
pub fn wait(&self) {
if !self.pending.load(Ordering::Acquire) {
return;
}
let mut landing = self.lock();
if !self.pending.load(Ordering::Acquire) {
return;
}
if let Some(l) = landing.as_mut() {
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
}
self.pending.store(false, Ordering::Release);
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
self.wait();
self.lock().as_mut().and_then(|l| l.landed.take())
}
}
impl Deref for Queue {
type Target = wgpu::Queue;
fn deref(&self) -> &wgpu::Queue {
self.wait();
&self.queue
}
}
impl Drop for Queue {
fn drop(&mut self) {
self.wait();
if let Some(thread) = &mut self.thread {
thread.jobs.take();
if let Some(handle) = thread.handle.take() {
let _ = handle.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage,
mapped_at_creation: false,
})
}
#[test]
fn writes_after_a_frame_on_the_thread_land_after_it() {
let Some((device, queue)) = device() else {
return;
};
let queue = Queue::new(queue, true);
assert!(queue.threaded());
use wgpu::BufferUsages as U;
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
for frame in 0..3u8 {
// The frame copies `source`; the next frame's write must not reach that copy.
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &copy, 0, 4);
let job = Job {
encoder,
surface: None,
after: None,
};
assert!(queue.submit_later(job).is_ok());
queue.write_buffer(&source, 0, &[frame + 10; 4]);
assert!(queue.landed().is_some(), "frame {frame} submitted");
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&copy, 0, &read, 0, 4);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let expected = if frame == 0 {
[1, 2, 3, 4]
} else {
[frame + 9; 4]
};
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
read.unmap();
}
}
}
+169 -2
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -84,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -138,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
@@ -437,14 +437,15 @@ impl Post {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
@@ -460,6 +461,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
@@ -467,7 +469,7 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
self.finish_with(encoder, screen, &taa.show[i], timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -476,6 +478,7 @@ impl Post {
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -488,6 +491,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
timestamp_writes: timing.and_then(|t| t.writes("Post")),
color_attachments: &attachments,
..Default::default()
});
+317 -8
View File
@@ -79,6 +79,47 @@ impl Preset {
}
}
/// Entity occlusion strategy. The synchronous mode preserves the original
/// same-frame CPU test; asynchronous results only hide a mesh when their
/// camera, bounds and occluder evidence still match.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum EntityCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl EntityCullingMode {
pub const ALL: [EntityCullingMode; 3] = [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
// Keep graphics files written before the mode selector readable.
"true" | "synchronous" | "sync" => Self::Synchronous,
"false" | "off" => Self::Off,
"asynchronous" | "async" => Self::Asynchronous,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -246,6 +287,61 @@ impl FarDetail {
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
/// Like `Solid`, but a leaf with one open side draws opaque too: the
/// exposed shell thins by a layer and the canopy looks chunkier.
SolidAggressive,
/// Drops the faces two leaves share. Faces toward air, cavities, partial
/// models, unknown neighbours and section boundaries stay.
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
];
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
}
}
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
}
}
/// Whether this mode draws interior leaf faces in the solid layer.
pub fn is_interior(self) -> bool {
matches!(
self,
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
)
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == s)
.unwrap_or_default()
}
}
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -313,6 +409,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -323,6 +421,19 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -346,6 +457,46 @@ impl Graphics {
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
lod_distance_capped(self.lod_distance, info)
}
/// Whether the selected packs leave vanilla's block models in place.
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
/// for those: a pack can replace an opaque cube's model or a leaf's.
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
resource_packs.iter().all(|id| id == "vanilla")
}
/// The same check including packs overlaid by the connected server.
/// Their ids are namespaced as `server/...`, so an active server pack
/// disables model-dependent occlusion assumptions.
pub fn vanilla_models_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> bool {
resource_packs
.into_iter()
.chain(server_packs)
.all(|id| id == "vanilla")
}
/// The leaf culling the mesher may apply. A pack that can replace leaf
/// models falls back to `Off`, which draws exactly vanilla's leaves.
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
if self.vanilla_models(resource_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
/// The effective mode when local and server resource packs are active.
pub fn leaf_culling_effective_with_server_packs<'a>(
&self,
resource_packs: impl IntoIterator<Item = &'a str>,
server_packs: impl IntoIterator<Item = &'a str>,
) -> LeafCullingMode {
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
self.leaf_culling
} else {
LeafCullingMode::Off
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Graphics {
@@ -356,16 +507,26 @@ pub struct Graphics {
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain.
pub entity_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -379,6 +540,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -405,6 +568,11 @@ pub struct Graphics {
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -417,13 +585,16 @@ impl Default for Graphics {
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
entity_culling: false,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -432,6 +603,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -441,6 +613,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -482,13 +655,18 @@ impl Graphics {
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -502,6 +680,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -522,6 +705,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -538,18 +722,21 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.entity_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -565,6 +752,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -575,6 +763,7 @@ impl Graphics {
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
@@ -695,13 +884,16 @@ mod tests {
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
entity_culling: true,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -710,6 +902,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -719,15 +912,46 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
);
assert_eq!(
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// 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);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -736,11 +960,96 @@ mod tests {
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
Graphics::parse(
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
),
Graphics::default()
);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let g = Graphics {
leaf_culling: mode,
..Graphics::default()
};
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
assert_eq!(
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
mode
);
}
for text in [
"leafCulling:other\n",
"leafCulling:\n",
"leafCulling:SOLID\n",
] {
assert_eq!(
Graphics::parse(text).leaf_culling,
LeafCullingMode::Off,
"{text:?}"
);
}
}
#[test]
fn presets_keep_the_leaf_culling_choice() {
for mode in LeafCullingMode::ALL {
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
s.horizon.leaf_culling = mode;
apply_preset(preset, &mut s);
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
}
}
}
#[test]
fn custom_packs_turn_leaf_culling_off() {
let g = Graphics {
leaf_culling: LeafCullingMode::Hollow,
..Graphics::default()
};
assert!(g.vanilla_models(&["vanilla".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into()]),
LeafCullingMode::Hollow
);
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
assert_eq!(
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
LeafCullingMode::Off
);
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
}
#[test]
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
let g = Graphics {
leaf_culling: LeafCullingMode::Solid,
..Graphics::default()
};
let selected = ["vanilla"];
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
LeafCullingMode::Off,
"an applied server pack can replace block models"
);
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
assert_eq!(
g.leaf_culling_effective_with_server_packs(selected, []),
LeafCullingMode::Solid,
"removing the server pack restores the selected local mode"
);
assert!(g.vanilla_models_with_server_packs(selected, []));
}
#[test]
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
+162 -16
View File
@@ -2,7 +2,10 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics::{
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
@@ -27,12 +30,14 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -40,6 +45,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
ScreenshotQuality,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -185,6 +191,12 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"How far away mobs and items are drawn, as a share of normal.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
),
Row::Header("Culling"),
h(
H::CaveCulling,
Saves,
@@ -193,12 +205,12 @@ pub fn rows(level: Level) -> Vec<Row> {
h(
H::EntityCulling,
None,
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
),
h(
H::ChunkUpdates,
Low,
"Meshed sections uploaded per frame. Lower is smoother while loading.",
H::LeafCulling,
Saves,
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
),
Row::Header("Quality"),
v(
@@ -260,6 +272,11 @@ pub fn rows(level: Level) -> Vec<Row> {
Low,
"Animates water, lava, fire and other moving textures.",
),
h(
H::DitherCutoutMips,
Low,
"Keeps fine plants in high-res packs from fading far away.",
),
Row::Header("Display"),
fov,
fps,
@@ -279,6 +296,11 @@ pub fn rows(level: Level) -> Vec<Row> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::ScreenshotQuality,
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::HdrSettings,
None,
@@ -434,6 +456,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
LeafCullingMode::SolidAggressive => {
"Leaves with one open side draw solid too: faster, chunkier edges."
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
_ => text,
}
}
@@ -503,7 +535,8 @@ impl H {
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
@@ -519,6 +552,7 @@ impl H {
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
@@ -544,6 +578,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -632,11 +669,17 @@ impl H {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::EntityCulling => g.entity_culling = !g.entity_culling,
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
H::Sky => g.sky = !g.sky,
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
H::Antialiasing => {
@@ -654,6 +697,13 @@ impl H {
(g.fxaa, g.taa) = (false, true);
}
}
H::ScreenshotQuality => {
g.screenshot_quality = step(
&crate::graphics::SCREENSHOT_QUALITIES,
g.screenshot_quality,
back,
)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -712,11 +762,11 @@ impl Item {
let g = &s.horizon;
Some(match self {
Item::Horizon(H::CaveCulling) => g.cave_culling,
Item::Horizon(H::EntityCulling) => g.entity_culling,
Item::Horizon(H::Sky) => g.sky,
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
@@ -775,6 +825,7 @@ impl Item {
Item::Horizon(
H::Preset
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
| H::PresentMode
| H::Workers
@@ -1743,6 +1794,21 @@ mod tests {
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let mut seen = Vec::new();
for _ in 0..4 {
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.screenshot_quality);
}
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
@@ -1785,16 +1851,92 @@ mod tests {
}
#[test]
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
let mut s = Settings::default();
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, [854, 480]);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
s.horizon.leaf_culling
);
}
item.cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
// The three culling settings share one group, below Rendering's chunk
// updates and above Quality.
let rows = rows(Level::Advanced);
let culling = rows
.iter()
.position(|row| matches!(row, Row::Header("Culling")))
.unwrap();
assert!(matches!(
rows.get(culling + 1),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
));
assert!(matches!(
rows.get(culling + 2),
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
));
assert!(matches!(
rows.get(culling + 3),
Some(Row::Setting(setting)) if setting.item == item
));
assert!(matches!(
rows.get(culling + 4),
Some(Row::Header("Quality"))
));
// The description names what the picked mode does.
for mode in LeafCullingMode::ALL {
s.horizon.leaf_culling = mode;
let text = description(item, &s, "fallback");
assert_ne!(text, "fallback", "{mode:?}");
assert!(text.len() < 80, "{mode:?}: {text}");
}
// A click cycles it and leaves the preset, as the other quality
// settings do.
let mut ui = GraphicsUi::default();
let size = (480.0, 4000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let control = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
s.horizon.preset = Preset::Balanced;
s.horizon.leaf_culling = LeafCullingMode::Off;
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(control.x + 1.0, control.y + 1.0),
size,
[854, 480],
false,
false
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.preset, Preset::Custom);
}
#[test]
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
let mut s = Settings::default();
let item = Item::Horizon(H::EntityCulling);
assert_eq!(item.switch(&s), Some(false));
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
for enabled in [false, true] {
s.horizon.entity_culling = enabled;
for mode in EntityCullingMode::ALL {
s.horizon.entity_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.entity_culling, enabled);
assert_eq!(s.horizon.entity_culling, mode);
}
}
@@ -1806,7 +1948,11 @@ mod tests {
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
let point = (switch.x + 1.0, switch.y + 1.0);
for enabled in [false, true] {
for mode in [
EntityCullingMode::Off,
EntityCullingMode::Synchronous,
EntityCullingMode::Asynchronous,
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
ui.click(
@@ -1820,10 +1966,10 @@ mod tests {
),
vec![Action::ButtonClick, Action::Changed]
);
assert_eq!(item.switch(&s), Some(enabled));
assert_eq!(s.horizon.entity_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
#[test]
+81 -16
View File
@@ -83,6 +83,8 @@ pub struct HudAtlas {
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -479,6 +481,7 @@ impl HudAtlas {
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
random_textures: Default::default(),
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -528,20 +531,33 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
// icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
for folder in ["entity", "mob_effect"] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&root.join(format!("assets/{namespace}/textures/{folder}")),
&mut files,
);
}
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
@@ -554,17 +570,49 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
let random_textures = if folders.contains(&"entity") {
horizon_optifine::random_textures::RandomTextures::scan(root)
} else {
Default::default()
};
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
extra.sort();
files.extend(extra);
}
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = 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}")
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()?;
@@ -758,6 +806,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
@@ -1604,6 +1653,9 @@ pub struct HudRenderer {
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
/// window (`Renderer::screenshot_quality`).
pub zoom: f32,
}
impl HudRenderer {
pub fn new(
@@ -1833,6 +1885,7 @@ impl HudRenderer {
format,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -1968,14 +2021,14 @@ impl HudRenderer {
[
size[0] as f32,
size[1] as f32,
batch.scale,
batch.scale * self.zoom,
batch.glint_alpha,
],
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
}
}
/// Draw the batch `prepare` staged.
@@ -1994,9 +2047,11 @@ impl HudRenderer {
batch,
size,
0..batch.vertices.len() as u32,
None,
);
}
/// Draw part of the prepared batch, preserving its blend/depth runs.
#[allow(clippy::too_many_arguments)]
pub fn draw_range(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -2005,6 +2060,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
vertices: std::ops::Range<u32>,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
if vertices.is_empty() || size.contains(&0) {
return;
@@ -2035,6 +2091,7 @@ impl HudRenderer {
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -2048,7 +2105,8 @@ impl HudRenderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
if let Some(clip) = clip {
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
@@ -2186,7 +2244,7 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
fn content_mod_entity_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
@@ -2206,10 +2264,17 @@ mod tests {
br#"["gems"]"#,
)
.unwrap();
// A block entity's texture, for a native mod's renderer.
let machine = root.join("assets/gems/textures/entity/machine");
fs::create_dir_all(&machine).unwrap();
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
let rect = atlas.sprites["gems:entity/machine/cog"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// Only entity textures: the mods' others reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
@@ -216,8 +216,9 @@ impl PackText {
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
scale: f32,
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
@@ -233,7 +234,8 @@ impl PackText {
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
@@ -287,7 +289,8 @@ mod tests {
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let data: Vec<f32> =
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
+2
View File
@@ -18,9 +18,11 @@ pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod connected;
pub mod containers;
pub mod crash;
pub mod datapack;
+277 -25
View File
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -494,7 +497,20 @@ struct Plan {
/// selection is rebuilt, without sorting the whole set.
by_level: [Vec<TileKey>; MAX_LEVEL as usize + 1],
elevation: i32,
camera: [i32; 2],
/// The squared distance within which no coarser tile stands in for
/// missing detail (`stands_in`): the seam with the near chunks waits
/// for its own tiles instead of showing broad boxes beside them.
near: i64,
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
/// load first.
const SEAM_CHUNKS: i32 = 3;
const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
@@ -518,6 +534,9 @@ impl Plan {
let size = 16 << level;
let mut result = Self {
elevation,
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -589,7 +608,7 @@ impl Plan {
if complete {
return true;
}
if ready.contains_key(&key) {
if ready.contains_key(&key) && self.stands_in(key) {
// Its mesh is clipped around every selected finer tile.
out.push(key);
return true;
@@ -602,6 +621,26 @@ impl Plan {
false
}
}
/// Whether a split tile may draw for its missing children: only away
/// from the near chunks, where its cells are small on screen.
fn stands_in(&self, key: TileKey) -> bool {
key.reach(self.camera) >= self.near
}
/// The coarse tile sampled to cover `leaf` while it loads: two levels
/// up (16 times fewer tiles, each as cheap as a leaf to sample), one
/// where that would reach the seam, none beside it.
fn backdrop(&self, leaf: TileKey) -> Option<TileKey> {
let mut backdrop = None;
let mut key = leaf;
for _ in 0..2 {
key = key.parent();
if !self.nodes.contains(&key) || !self.stands_in(key) {
break;
}
backdrop = Some(key);
}
backdrop
}
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
/// at level 0, also `filled`) rather than by a coarser ancestor standing
/// in for it, or is past the far terrain.
@@ -625,6 +664,47 @@ impl Plan {
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
}
}
/// The tiles to sample, `Wanted`'s order best first. First the planned
/// tiles at the seam with the near chunks, nearest first: coarse tiles
/// there cost a far tile's work for broad boxes that fine tiles replace
/// moments later. Then a coarse backdrop (`Plan::backdrop`) out to the
/// horizon, then every other planned tile nearest first, each replacing its
/// part of the backdrop: the horizon fills in early and sharpens from the
/// player outward, whichever way they look.
fn wanted_tiles(
plan: &Plan,
ready: &FxHashMap<TileKey, impl Sized>,
camera: [i32; 2],
) -> impl Iterator<Item = Wanted> {
let wanted = |class, key: TileKey| Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
};
let mut backdrop = FxHashSet::default();
let mut tiles: Vec<_> = plan
.nodes
.iter()
.filter(|key| !plan.split.contains(key) && !ready.contains_key(key))
.map(|&key| {
if let Some(coarse) = plan.backdrop(key)
&& !ready.contains_key(&coarse)
{
backdrop.insert(coarse);
}
let first = key.reach(camera) < plan.first;
wanted(if first { SEAM } else { REST }, key)
})
.collect();
tiles.extend(backdrop.into_iter().map(|key| wanted(BACKDROP, key)));
tiles.into_iter()
}
/// `Wanted::class`es, best first.
const SEAM: u8 = 0;
const BACKDROP: u8 = 1;
const REST: u8 = 2;
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
@@ -791,9 +871,11 @@ impl Ord for MeshQueued {
.then(other.order.cmp(&self.order))
}
}
/// A tile the sampler should produce next, nearest first.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
/// A tile the sampler should produce next: by `class`
/// (`wanted_tiles`), then nearest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Wanted {
class: u8,
distance: i64,
x: i32,
z: i32,
@@ -843,6 +925,8 @@ pub struct Lod {
near_loading: bool,
/// When `request_samples` last started a job while held.
held_sample_at: std::time::Instant,
/// Tiles whose sampling failed, and when they may be asked again.
retry: FxHashMap<TileKey, std::time::Instant>,
/// Each tile's samples, shared with mesh jobs in flight.
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
/// Fully covered subtrees, by tile.
@@ -1179,6 +1263,7 @@ impl Lod {
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
@@ -1411,14 +1496,15 @@ impl Lod {
let present = self.ready.contains_key(&key);
// Covered level-0 stand-ins complete their parent but are never drawn.
let ready = present && !(key.level == 0 && self.covered.contains(&(key.x, key.z)));
// Mirrors `Plan::select`: a ready split tile is a complete
// stand-in even while its children are still incomplete, so no
// second fallback is drawn above it.
// Mirrors `Plan::select`: a ready split tile away from the seam is a
// complete stand-in even while its children are still incomplete,
// so no second fallback is drawn above it.
let (complete, active) = if self.plan.split.contains(&key) {
let children = key.children().into_iter().all(|child| {
!self.plan.nodes.contains(&child)
|| self.complete.get(&child).copied().unwrap_or(false)
});
let ready = ready && self.plan.stands_in(key);
(children || ready, ready && !children)
} else {
(present, ready)
@@ -1609,7 +1695,16 @@ impl Lod {
}
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
self.pending.remove(&key);
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
if samples.len() != CELLS * CELLS {
let now = std::time::Instant::now();
if self.retry.len() > 4096 {
self.retry.retain(|_, at| *at > now);
}
self.retry.insert(key, now + RETRY);
return;
}
self.retry.remove(&key);
if self.plan.nodes.contains(&key) {
let mut samples = samples;
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
for i in 0..256 {
@@ -2161,8 +2256,16 @@ impl Lod {
if free == 0 {
return;
}
let now = std::time::Instant::now();
if self.held() {
self.held_sample_at = std::time::Instant::now();
self.held_sample_at = now;
}
// A tile whose sampling failed waits out `RETRY`; once it has, the
// queue is rebuilt so the tile is asked again.
let retrying = self.retry.len();
self.retry.retain(|_, at| *at > now);
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// The queue is rebuilt when the view moves a chunk, not scanned and
// sorted on every frame.
@@ -2183,12 +2286,12 @@ impl Lod {
z: wanted.z,
level: wanted.level,
};
// Final tiles only: a coarse tile under the near area is work
// that finer tiles replace moments later.
// Final tiles and the backdrop only (`wanted_tiles`).
if self.pending.contains(&key)
|| self.ready.contains_key(&key)
|| self.plan.split.contains(&key)
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
{
continue;
}
@@ -2203,17 +2306,19 @@ impl Lod {
}
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
self.wanted.clear();
for &key in &self.plan.nodes {
if self.plan.split.contains(&key) || self.ready.contains_key(&key) {
continue;
}
self.wanted.push(std::cmp::Reverse(Wanted {
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
}));
}
// A failed tile waits out its retry delay (`retry`) before it is asked again.
let retry = &self.retry;
self.wanted.extend(
wanted_tiles(&self.plan, &self.ready, camera)
.filter(|w| {
!retry.contains_key(&TileKey {
x: w.x,
z: w.z,
level: w.level,
})
})
.map(std::cmp::Reverse),
);
}
#[cfg(test)]
@@ -2607,14 +2712,14 @@ mod tests {
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
};
// 24 chunks out, now past the render distance: its planned tile was
// split before, so never sampled, and a root stands in for it.
// split before, so never sampled. The ready root reaches the seam, so
// it stands in for nothing: the column stays until its tile is in.
let column = (24, 0);
let mut selected = Vec::new();
for &root in &after.roots {
after.select(root, &ready, &mut selected);
}
let drawn = selected.iter().filter(|key| contains(key, column));
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
assert!(!selected.iter().any(|key| contains(key, column)));
assert!(!after.replaces(column, &ready, |_| true));
let planned = TileKey {
x: 12,
@@ -3258,6 +3363,153 @@ mod tests {
Ok(())
}
#[test]
fn far_cells_past_render_distance_12_start_one_block_wide() {
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
// keeps 1 to the near terrain's edge and 2 to 256.
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
assert_eq!(key.size() / 16, 1, "{key:?} reduced {reduced}");
} else if distance < two {
assert!(key.size() / 16 <= 2, "{key:?} reduced {reduced}");
}
}
}
}
/// Loads a render distance 12 view one sample job at a time, in
/// `wanted_tiles`' order. Counts the jobs, and their density lattice
/// points (each job's cost), before every far column within two chunks
/// of the near terrain is drawn as finely as the plan wants it (one
/// block wide in Full): until then nothing is drawn there. And before
/// every far column is drawn, by its tile or a coarse stand-in: until
/// then the far terrain ends short of the horizon. Checks that no coarse
/// tile is ever drawn at the seam.
fn jobs_until_fine_seam(camera: [i32; 2], reduced: bool) -> ([(usize, i32); 2], usize) {
let (detail, distance) = (12, 128);
let center = (camera[0].div_euclid(16), camera[1].div_euclid(16));
let mut covered = FxHashSet::default();
for z in center.1 - detail..=center.1 + detail {
for x in center.0 - detail..=center.0 + detail {
if crate::streaming::in_view_distance(center, detail, x, z) {
covered.insert((x, z));
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
.find(|key| plan.nodes.contains(key) && !plan.split.contains(key))
};
let mut seam = Vec::new();
for z in (camera[1] - 256..camera[1] + 256).step_by(2) {
for x in (camera[0] - 256..camera[0] + 256).step_by(2) {
let chunk = (x.div_euclid(16), z.div_euclid(16));
let near = (-2..=2)
.any(|dz| (-2..=2).any(|dx| covered.contains(&(chunk.0 + dx, chunk.1 + dz))));
if !covered.contains(&chunk) && near {
seam.push((x, z, leaf(x, z).unwrap()));
}
}
}
let mut horizon = Vec::new();
for z in (camera[1] - 2048..camera[1] + 2048).step_by(64) {
for x in (camera[0] - 2048..camera[0] + 2048).step_by(64) {
if !covered.contains(&(x.div_euclid(16), z.div_euclid(16))) && leaf(x, z).is_some()
{
horizon.push((x, z));
}
}
}
let mut ready: FxHashMap<TileKey, ()> = FxHashMap::default();
for key in &plan.nodes {
if key.level == 0 && covered.contains(&(key.x, key.z)) {
ready.insert(*key, ());
}
}
let leaves = plan
.nodes
.iter()
.filter(|k| !plan.split.contains(k))
.count();
let (mut cost, mut fine, mut reached) = (0, None, None);
for jobs in 0.. {
let mut selected = Vec::new();
for &root in &plan.roots {
plan.select(root, &ready, &mut selected);
}
let active: FxHashSet<_> = selected.into_iter().collect();
let drawn = |x: i32, z: i32, levels: u8| {
(0..=levels).any(|level| {
active.contains(&TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
})
};
for &(x, z, leaf) in &seam {
assert!(drawn(x, z, leaf.level) || !drawn(x, z, MAX_LEVEL));
}
if fine.is_none() && seam.iter().all(|&(x, z, leaf)| drawn(x, z, leaf.level)) {
fine = Some((jobs, cost));
}
if reached.is_none() && horizon.iter().all(|&(x, z)| drawn(x, z, MAX_LEVEL)) {
reached = Some((jobs, cost));
}
if let (Some(fine), Some(reached)) = (fine, reached) {
return ([fine, reached], leaves);
}
let next = wanted_tiles(&plan, &ready, camera).min().unwrap();
let next = TileKey {
x: next.x,
z: next.z,
level: next.level,
};
// `lod_grid`'s density lattice: one point per max(stride, 4) blocks.
let stride = next.size() / 16;
let lattice = 16 * stride / stride.max(4) + 1;
cost += lattice * lattice;
ready.insert(next, ());
}
unreachable!()
}
#[test]
fn the_far_terrain_next_to_the_near_chunks_loads_first() {
// Measured: 270 to 301 jobs, all of them fine tiles (7500 to 8500
// lattice points). A coarse tile first costs up to 289 points; the
// order that filled a ring ahead first and refined broad tiles every
// other job took 54000 to 119000 for the seam in view alone.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([(jobs, cost), _], _) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 320, "{jobs} jobs");
assert!(cost < 9000, "{cost} lattice points");
}
}
}
#[test]
fn a_coarse_backdrop_reaches_the_horizon_early() {
// Sampling only planned tiles, nearest first, reached the horizon
// with the last of ~3000 jobs in Full (500000 lattice points), ~1050
// in Reduced (200000): for minutes on a slow machine the far terrain
// ended in a hard edge. The backdrop adds ~230 coarse tiles (~80 in
// Reduced), about a tenth more work in all.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([_, (jobs, cost)], leaves) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 1000 && jobs < leaves, "{jobs} jobs of {leaves}");
assert!(cost < 100_000, "{cost} lattice points");
}
}
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
+8 -1
View File
@@ -160,7 +160,14 @@ impl Mesher {
for slot in &mut faces {
let Some(face) = matching.next() else { break };
*slot = Some(Style {
layer: face.layer as u16 | if leaves { OPAQUE_LAYER } else { 0 },
// As the mesher marks them: opaque leaves and faces over opaque
// texels skip the cutout and draw in the opaque pass.
layer: face.layer as u16
| if leaves || face.opaque {
OPAQUE_LAYER
} else {
0
},
tint: if face.quad.tint_index >= 0 {
tint
} else {
@@ -59,6 +59,55 @@ pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// `--player-dir DIR` (#4952), set once at start: one player's own files, when several games
/// play in one game folder at the same time (the launcher's `play --account`).
static PLAYER: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes [`player_dir`] return `dir` for the rest of the run (`--player-dir`), creating it.
/// What is a player's own goes there: `options.txt` and the settings files beside it,
/// `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`, `hotbar.nbt` and
/// `screenshots/`. What the game folder's players share stays in it: saves, resource and
/// shader packs, mods and their config, caches.
pub fn set_player_dir(dir: PathBuf) {
let _ = fs::create_dir_all(&dir);
let _ = PLAYER.set(dir);
}
/// The folder of this player's own files: `--player-dir`, else the game folder (the working
/// directory, where the launcher runs the game).
pub fn player_dir() -> PathBuf {
PLAYER
.get()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
/// `--player-dir`, if given.
pub fn player_dir_override() -> Option<&'static Path> {
PLAYER.get().map(PathBuf::as_path)
}
/// Where the files all players share live, for settings kept beside `options.txt` in
/// `options_dir` (the enabled mods): that folder, unless `--player-dir` moved `options.txt`
/// out of the game folder.
pub fn shared_dir(options_dir: &Path) -> PathBuf {
shared_dir_of(PLAYER.get().map(PathBuf::as_path), options_dir, || {
std::env::current_dir().unwrap_or_default()
})
}
/// [`shared_dir`] for a given `--player-dir` and game folder.
fn shared_dir_of(
player: Option<&Path>,
options_dir: &Path,
game_dir: impl FnOnce() -> PathBuf,
) -> PathBuf {
match player {
Some(player) if player == options_dir => game_dir(),
_ => options_dir.to_path_buf(),
}
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
@@ -567,6 +616,20 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[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"));
// options.txt moved to the player's folder: the enabled mods stay the game folder's.
assert_eq!(shared_dir_of(Some(player), player, || game.into()), game);
// No --player-dir, or a splitscreen profile's own folder: beside options.txt as before.
assert_eq!(shared_dir_of(None, game, || unreachable!()), game);
let profile = Path::new("/games/shared/splitscreen/profiles/P2");
assert_eq!(
shared_dir_of(Some(player), profile, || unreachable!()),
profile
);
}
#[test]
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
let (mut floods, start) = (Floods::default(), Instant::now());
@@ -172,6 +172,7 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
@@ -224,6 +225,7 @@ impl MenuBlur {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
+63 -8
View File
@@ -4,6 +4,7 @@
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
//! follow `FluidRenderer` (see `fluid`).
mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::{
@@ -11,6 +12,7 @@ use crate::{
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_core::{
@@ -231,6 +233,9 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Blocks that give off light (world position, state id), for the colour of block light
/// Horizon's shader packs read (`gfx::block_light`).
pub lights: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
@@ -248,6 +253,7 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
@@ -278,6 +284,7 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
@@ -288,6 +295,7 @@ impl SectionMesh {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
@@ -464,6 +472,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
blocks_flow: bool,
ice: bool,
sturdy: u8,
@@ -641,6 +652,12 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
s.solid_render,
s.full_collision,
),
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
ice: s.has_class("IceBlock"),
sturdy: s.sturdy_full,
@@ -680,6 +697,14 @@ impl MeshTables {
tint_sources: i.tint_count,
})
}
/// Whether `state` is a full opaque cube: the only block leaf culling
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
/// that only partly fill their block are not.
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
self.info
.get(state.0 as usize)
.is_some_and(|i| i.opaque_cube)
}
/// `BlockTintSource.color(state)`: block-model colour without a level.
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
/// Untinted faces, blank sources and invalid tint indices return `None`.
@@ -854,14 +879,18 @@ impl Padded {
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
@@ -1073,6 +1102,8 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
@@ -1102,6 +1133,11 @@ impl Mesher<'_> {
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
.leaf
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
}
let mut tints = Tints::new(
region,
self.biomes,
@@ -1124,6 +1160,10 @@ impl Mesher<'_> {
out.block_entities
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.emission > 0 {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
@@ -1162,13 +1202,21 @@ impl Mesher<'_> {
None => [x as f32, y as f32, z as f32],
};
let mut visible = [false; 6];
let interior_leaf = state.leaves
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& self
.visibility
.should_render_face(
@@ -1193,6 +1241,7 @@ impl Mesher<'_> {
face,
ao,
state,
interior_leaf,
);
}
}
@@ -1231,6 +1280,7 @@ impl Mesher<'_> {
face: &BakedFace,
ao: bool,
state: &Info,
interior_leaf: bool,
) {
let q = &face.quad;
// A quad's own light (a model's glowing part) may raise the tier; whether it
@@ -1265,6 +1315,17 @@ impl Mesher<'_> {
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !face.translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let g = gray[i] as u32;
let rgb = match tint {
@@ -1275,13 +1336,7 @@ impl Mesher<'_> {
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
},
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
@@ -0,0 +1,656 @@
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
//!
//! The mask is built on the mesh worker from the same padded block snapshot the
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
//! open (air, fluids, partial models, anything else) or unknown (a missing
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
//! hide or solidify anything; unknown and open cells always expose a face.
//!
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
//! enclosed leaves into the opaque layer without cutout testing. `Solid
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
/// A cell of the padded snapshot, as far as leaf culling cares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Cell {
/// Air, a fluid, a partial model, glass or any other non-occluding block.
Open,
/// `LeavesBlock`.
Leaf,
/// A full opaque cube (see [`opaque_cube`]).
Opaque,
/// Missing chunk, missing section or the mask's outer shell.
Unknown,
}
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
/// chests and models that only partly fill their block are not.
pub(super) fn opaque_cube(
shape: RenderShape,
can_occlude: bool,
solid_render: bool,
full_collision: bool,
) -> bool {
shape == RenderShape::Model && can_occlude && solid_render && full_collision
}
const UNVISITED: u8 = u8::MAX;
const STRIDE_X: isize = 1;
const STRIDE_Z: isize = P as isize;
const STRIDE_Y: isize = (P * P) as isize;
const ADJACENT_STRIDES: [isize; 6] = [
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
];
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
/// between sections and modes; `Off` does no work at all.
#[derive(Default)]
pub(super) struct LeafMask {
cells: Vec<Cell>,
depth: Vec<u8>,
queue: Vec<u16>,
active: bool,
}
impl LeafMask {
/// Classify the padded snapshot and, for the interior modes, measure how
/// deep each leaf sits below the canopy surface.
pub(super) fn prepare(
&mut self,
mode: LeafCullingMode,
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
) {
self.active = mode != LeafCullingMode::Off;
if !self.active {
return;
}
// Most sections hold no leaves at all; do not classify 8,000 cells to
// learn that. The centre is the only part this mesh can change.
let mut leaves = false;
'scan: for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
if info
.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves)
{
leaves = true;
break 'scan;
}
}
}
}
if !leaves {
self.active = false;
return;
}
if self.cells.len() != P * P * P {
self.cells.resize(P * P * P, Cell::Open);
}
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
self.cells[i] = if !known(region, base_y, x, y, z) {
Cell::Unknown
} else {
match info.get(pad.blocks[i] as usize) {
None => Cell::Unknown,
Some(state) if state.leaves => Cell::Leaf,
Some(state) if state.opaque_cube => Cell::Opaque,
Some(_) => Cell::Open,
}
};
}
}
}
// Solid is a purely local six-neighbour test. Reserve the breadth-first
// search for Solid Aggressive, whose one-open-side rule needs to know
// whether a leaf is connected to the canopy surface through other
// leaves.
if mode == LeafCullingMode::SolidAggressive {
self.depths();
}
}
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
fn depths(&mut self) {
if self.depth.len() != self.cells.len() {
self.depth.resize(self.cells.len(), UNVISITED);
} else {
self.depth.fill(UNVISITED);
}
self.queue.clear();
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let i = pi(x, y, z);
if self.cells[i] != Cell::Leaf {
continue;
}
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Open | Cell::Unknown)
});
if touches_open {
self.depth[i] = 0;
self.queue.push(i as u16);
}
}
}
}
let mut head = 0;
while head < self.queue.len() {
let i = self.queue[head] as usize;
head += 1;
let next_depth = self.depth[i].saturating_add(1);
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
self.depth[j] = next_depth;
self.queue.push(j as u16);
}
}
}
}
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
///
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
/// with a single open side joins the opaque core, so the cutout shell is
/// one layer thinner. Unknown neighbours never qualify, however many sides
/// they leave open. A leaf the search never reached sits inside a closed
/// volume: interior for both.
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
if !self.active {
return false;
}
let aggressive = match mode {
LeafCullingMode::Solid => false,
LeafCullingMode::SolidAggressive => true,
_ => return false,
};
// A section boundary is not proof of a closed volume: the neighbouring
// section may not be drawn yet, and its data is not ours to assume.
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
return false;
}
let i = pi(x, y, z);
if self.cells.get(i) != Some(&Cell::Leaf) {
return false;
}
if !aggressive {
return ADJACENT_STRIDES.iter().all(|&stride| {
let j = (i as isize + stride) as usize;
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
});
}
let depth = self.depth[i];
if depth == UNVISITED || depth >= 1 {
return true;
}
let mut open = 0;
for stride in ADJACENT_STRIDES {
let j = (i as isize + stride) as usize;
match self.cells[j] {
Cell::Unknown => return false,
Cell::Open => {
open += 1;
if open > 1 {
return false;
}
}
Cell::Leaf | Cell::Opaque => {}
}
}
open == 1
}
}
/// The cells this section's mesh owns. A face on this boundary belongs to the
/// neighbouring section as much as to this one; leaf culling leaves it alone.
#[inline]
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
/// for the same reason. The mask's outer shell is unknown too, so nothing is
/// inferred past the data the worker snapshotted.
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
return false;
}
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
}
#[cfg(test)]
impl LeafMask {
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
/// Only a known leaf on the other side counts: open cells, partial models,
/// missing neighbours and the mask's edge stay visible.
pub(super) fn hides_face(
&self,
mode: LeafCullingMode,
x: i32,
y: i32,
z: i32,
d: horizon_core::geometry::Direction,
) -> bool {
if !self.active || mode != LeafCullingMode::Hollow {
return false;
}
let [dx, dy, dz] = d.normal();
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
return false;
}
let i = pi(x, y, z);
let ni = pi(nx, ny, nz);
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
}
#[inline]
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
checked(x, y, z)
.and_then(|i| self.cells.get(i))
.copied()
.unwrap_or(Cell::Unknown)
}
}
#[cfg(test)]
#[inline]
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
Some(pi(x, y, z))
} else {
None
}
}
#[cfg(test)]
#[inline]
fn unpi(i: usize) -> (i32, i32, i32) {
let x = (i % P) as i32 - B;
let z = (i / P % P) as i32 - B;
let y = (i / (P * P)) as i32 - B;
(x, y, z)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
use horizon_core::geometry::Direction;
use horizon_world::fluid::FluidState;
use std::time::Instant;
fn info(leaves: bool, opaque: bool) -> Info {
Info {
solid: opaque,
full: opaque,
permeable: false,
shade: 1.0,
emission: 0,
tier: 0,
emissive: false,
fluid: FluidState::EMPTY,
model: true,
block_entity: false,
tints: [super::super::Tint::None; 2],
tint_count: 0,
offset: None,
legacy_solid: false,
overlay: leaves,
leaves,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
sturdy: 0,
face: [0; 6],
cover: [0.0; 6],
}
}
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
fn states() -> Vec<Info> {
vec![
info(false, false),
info(true, false),
info(false, true),
info(false, false),
]
}
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
Padded {
blocks: (0..P * P * P)
.map(|i| {
let (x, y, z) = unpi(i);
fill(x, y, z)
})
.collect(),
light: vec![0; P * P * P],
}
}
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
for y in min..=max {
for z in min..=max {
for x in min..=max {
pad.blocks[pi(x, y, z)] = state;
}
}
}
}
fn loaded() -> Chunk {
Chunk::empty(0, 0)
}
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
let mut mask = LeafMask::default();
mask.prepare(mode, pad, states, &region(chunk), 0);
mask
}
#[test]
fn only_a_full_model_cube_is_opaque() {
let combinations = [
(RenderShape::Model, true, true, true),
(RenderShape::Model, true, true, false),
(RenderShape::Model, true, false, true),
(RenderShape::Model, false, true, true),
(RenderShape::Model, false, false, false),
(RenderShape::Invisible, true, true, true),
(RenderShape::Animated, true, true, true),
];
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
assert!(opaque_cube(
shape,
can_occlude,
solid_render,
full_collision
));
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
assert!(
!opaque_cube(shape, can_occlude, solid_render, full_collision),
"{shape:?} {can_occlude} {solid_render} {full_collision}"
);
}
}
#[test]
fn solid_marks_the_cell_inside_a_three_cube() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
// Aggressive also takes the six face-centre leaves, whose only open
// side faces out of the cube. Corners and edges keep their cutout.
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
}
#[test]
fn aggressive_takes_the_next_shell_layer() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
let surface = |v: i32| v == 6 || v == 10;
for [x, y, z] in [
[6, 8, 8],
[10, 8, 8],
[6, 6, 8],
[6, 6, 6],
[7, 8, 8],
[8, 8, 8],
[7, 7, 7],
[8, 8, 7],
] {
let expected = !surface(x) && !surface(y) && !surface(z);
assert_eq!(
solid.interior(LeafCullingMode::Solid, x, y, z),
expected,
"solid {x} {y} {z}"
);
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
let expected = open <= 1;
assert_eq!(
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
expected,
"aggressive {x} {y} {z}"
);
}
}
#[test]
fn hollow_hides_only_leaf_to_leaf_faces() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
// Glass (3, a partial model), water and a slab are open: their leaves
// must keep the face.
pad.blocks[pi(10, 8, 8)] = 3;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
// The other modes never hide a quad.
for mode in [
LeafCullingMode::Off,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
] {
let mask = built(mode, &pad, &states, &chunk);
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
}
}
#[test]
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 6, 10, 1);
pad.blocks[pi(8, 8, 8)] = 0;
let chunk = loaded();
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
// The corner away from the cavity is still two layers in.
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
}
#[test]
fn an_opaque_neighbour_does_not_expose_a_leaf() {
let states = states();
let mut pad = padded(|_, _, _| 0);
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
}
pad.blocks[pi(8, 8, 8)] = 1;
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
for d in Direction::ALL {
assert!(
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
"{d:?}"
);
}
}
#[test]
fn a_leaf_inside_a_closed_volume_is_interior() {
let states = states();
// A leaf cube inside opaque rock: the search has no opening to start
// from, so every leaf stays unvisited and counts as interior.
let mut pad = padded(|_, _, _| 2);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mask = built(mode, &pad, &states, &chunk);
if mode == LeafCullingMode::SolidAggressive {
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
}
}
#[test]
fn missing_chunks_and_sections_are_unknown() {
let states = states();
let pad = padded(|_, _, _| 1);
let chunk = loaded();
let mut around = [[Some(&chunk); 3]; 3];
around[1][2] = None; // east chunk
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
// The leaf at the edge keeps its face toward the missing chunk, even
// though `Padded::fill` copied the centre's edge leaf there.
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
// A leaf one block inside is still between two known leaves.
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
// A section above the column is unknown too: the same leaf one
// section up has no data, so it can never be interior.
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&short), 16);
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
// The outer shell has no data beyond it.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &region(&chunk), 0);
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
// A section boundary is never enclosed, even with every chunk loaded:
// the neighbouring section may not have been meshed yet.
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Solid, &pad, &states, &region(&chunk), 0);
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
}
#[test]
fn off_does_no_work_and_hides_nothing() {
let states = states();
let mut pad = padded(|_, _, _| 0);
cube(&mut pad, 7, 9, 1);
let chunk = loaded();
let mut mask = LeafMask::default();
mask.prepare(LeafCullingMode::Off, &pad, &states, &region(&chunk), 0);
assert!(!mask.active);
assert!(mask.cells.is_empty());
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
}
#[test]
fn a_reused_mask_reclassifies_every_cell() {
let states = states();
let chunk = loaded();
let mut mask = LeafMask::default();
let mut leaves = padded(|_, _, _| 0);
cube(&mut leaves, 7, 9, 1);
mask.prepare(
LeafCullingMode::Hollow,
&leaves,
&states,
&region(&chunk),
0,
);
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
let mut other = padded(|_, _, _| 0);
cube(&mut other, 10, 12, 1);
mask.prepare(LeafCullingMode::Hollow, &other, &states, &region(&chunk), 0);
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
}
#[test]
#[ignore = "microbenchmark; run with --ignored --nocapture"]
fn mask_build_benchmark() {
let states = states();
let mut pad = padded(|x, y, z| {
// Alternating leaf slabs make the search visit most of the volume.
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
1
} else {
0
}
});
cube(&mut pad, 6, 10, 1);
let chunk = loaded();
let region = region(&chunk);
let iterations = 1000;
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
let mut mask = LeafMask::default();
let started = Instant::now();
for _ in 0..iterations {
mask.prepare(mode, &pad, &states, &region, 0);
}
let per = started.elapsed() / iterations;
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
assert!(mask.active);
if mode == LeafCullingMode::SolidAggressive {
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
}
}
}
}
@@ -0,0 +1,293 @@
//! Leaf culling through the real mesher: face counts, the opaque layer and the
//! conservative cases (cavities, partial neighbours, missing chunks, column
//! ends). Needs prepared resources, like the other mesher geometry tests.
use super::*;
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, ColumnLayout},
graphics::LeafCullingMode,
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 leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
for z in min..=max {
for x in min..=max {
chunk.set(x, y, z, state);
}
}
}
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
let mut chunk = Chunk::empty(0, 0);
let state = state(f, name);
for y in base..base + (max - min + 1) {
leaves_cube(&mut chunk, min, max, y, state);
}
chunk
}
fn mesh(
f: &Fixture,
_chunk: &Chunk,
region: [[Option<&Chunk>; 3]; 3],
sy: i32,
mode: LeafCullingMode,
) -> SectionMesh {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: 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,
leaf_culling: mode,
};
mesher.mesh(&region, sy)
}
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
[[Some(chunk); 3]; 3]
}
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
fn quads(mesh: &SectionMesh) -> (usize, usize) {
let mut opaque = 0;
let mut cutout = 0;
for quad in mesh.solid.as_chunks::<4>().0 {
let flags = quad
.iter()
.map(|v| v.layer & OPAQUE_LAYER != 0)
.collect::<Vec<_>>();
assert!(
flags.iter().all(|flag| *flag == flags[0]),
"a quad mixed opaque and cutout vertices: {flags:?}"
);
if flags[0] {
opaque += 1;
} else {
cutout += 1;
}
}
assert_eq!(mesh.solid.len() % 4, 0);
(opaque, cutout)
}
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_draws_every_fancy_leaf_face() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
assert_eq!(quads(&mesh), (0, 162));
assert_eq!(mesh.translucent.len(), 0);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn solid_draws_the_fully_enclosed_leaf_opaque() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// Only the centre has all six sides covered: its six quads go opaque.
assert_eq!(quads(&solid), (6, 156));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
assert_eq!(quads(&aggressive), (42, 120));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
let f = fixture();
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The 3x3x3 inside the surface: 27 blocks, six quads each.
assert_eq!(quads(&solid), (162, 588));
let aggressive = mesh(
&f,
&chunk,
around(&chunk),
0,
LeafCullingMode::SolidAggressive,
);
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
assert_eq!(quads(&aggressive), (486, 264));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn hollow_drops_only_the_faces_two_leaves_share() {
let f = fixture();
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn mixed_species_count_as_one_leaf_volume() {
let f = fixture();
let oak = state(&f, "minecraft:oak_leaves");
let birch = state(&f, "minecraft:birch_leaves");
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
for z in 6..=8 {
for x in 6..=8 {
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
}
}
}
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
assert_eq!(quads(&solid), (6, 156));
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
assert_eq!(quads(&hollow), (0, 54));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
let f = fixture();
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
chunk.set(7, BASE + 2, 7, 0);
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
// Five-cube shell (150) plus the six faces around the one-block cavity.
assert_eq!(quads(&hollow), (0, 156));
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// The six leaves around the cavity are exposed, so they stay cutout; the
// other 20 inner leaves draw solid. The cavity block itself has no quads.
assert_eq!(quads(&solid), (120, 624));
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
// A loaded east chunk whose x=0 column continues the canopy.
let mut east = Chunk::empty(1, 0);
for y in BASE..BASE + 4 {
for z in 12..=15 {
east.set(0, y, z, leaf);
}
}
let mut region = around(&chunk);
region[1][2] = Some(&east);
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
let mut region = around(&chunk);
region[1][2] = None;
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
// The east faces of the x=15 leaves are on the section boundary: a loaded
// neighbour does not let Hollow drop them, and a missing one does not
// change the mesh either.
assert_eq!(joined.solid.len(), missing.solid.len());
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
assert!(joined.solid.len() < off.solid.len());
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn the_top_of_a_one_section_column_stays_visible() {
let f = fixture();
let leaf = state(&f, "minecraft:oak_leaves");
let layout = ColumnLayout::new(0, 16).unwrap();
let mut chunk = Chunk::with_layout(0, 0, layout);
for y in 13..16 {
leaves_cube(&mut chunk, 5, 7, y, leaf);
}
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
// y=15 is the column's top: the faces toward y=16 are unknown, so they
// stay, and the y=15 leaves are not enclosed.
assert!(!hollow.solid.is_empty());
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
assert!(
solid
.solid
.iter()
.filter(|v| v.pos[1] == 16.0)
.all(|v| v.layer & OPAQUE_LAYER == 0)
);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_world_without_leaves_meshes_identically_in_every_mode() {
let f = fixture();
let mut chunk = Chunk::empty(0, 0);
for y in BASE..BASE + 3 {
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
}
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
for mode in LeafCullingMode::ALL {
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
for (a, b) in off.solid.iter().zip(&other.solid) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
for (a, b) in off.translucent.iter().zip(&other.translucent) {
assert_eq!(
(a.pos, a.uv, a.layer, a.light, a.color),
(b.pos, b.uv, b.layer, b.light, b.color),
"{mode:?}"
);
}
}
}
@@ -81,6 +81,7 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
.mesh(&region, 0)
@@ -156,6 +157,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
(&empty, None),
@@ -13,9 +13,10 @@
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sorted[*slot as usize] = *quad;
*slot += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
@@ -368,4 +391,46 @@ mod tests {
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
#[test]
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [30, 31, 10]);
}
#[test]
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [10, 20, 30]);
}
}
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
sturdy: 0,
+123
View File
@@ -38,6 +38,21 @@ pub struct Settings {
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub item: bool,
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
pub dither_cutouts: bool,
}
/// The `ditherCutoutMips` setting, for textures loaded from now on.
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
pub fn set_dither_cutouts(on: bool) -> bool {
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
}
/// The `ditherCutoutMips` setting.
pub fn dither_cutouts() -> bool {
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
}
/// A square or rectangular RGBA8 image, rows top to bottom.
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
}
out.push(data);
}
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
dither_cutout_levels(&mut out);
}
out
}
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
const DITHERED_CUTOUT_SIZE: u32 = 64;
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
/// its texels are in or out, about as many in as the base has.
fn dither_cutout_levels(levels: &mut [Image]) {
const BAYER: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
let mut alpha: Vec<f32> = levels[0]
.pixels
.iter()
.map(|p| f32::from(p[3]) / 255.0)
.collect();
for level in &mut levels[1..] {
let (nw, nh) = (level.width as usize, level.height as usize);
let mut next = vec![0.0; nw * nh];
for y in 0..nh {
for x in 0..nw {
let at = |dx: usize, dy: usize| {
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
};
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
next[y * nw + x] = mean;
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
}
}
alpha = next;
(w, h) = (nw, nh);
}
}
#[cfg(test)]
mod dither_tests {
use super::*;
#[test]
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
let cutout = |side: u32| Image {
width: side,
height: side,
pixels: (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect(),
};
let settings = |dither_cutouts| Settings {
has_transparent: true,
dither_cutouts,
..Settings::default()
};
assert_ne!(
generate(cutout(128), 4, settings(false)),
generate(cutout(128), 4, settings(true))
);
assert_eq!(
generate(cutout(32), 4, settings(false)),
generate(cutout(32), 4, settings(true))
);
}
#[test]
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
// Blades one texel wide, one column in four: too fine for the mips to hold.
let side = 128u32;
let pixels = (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect();
let base = Image {
width: side,
height: side,
pixels,
};
let settings = Settings {
has_transparent: true,
dither_cutouts: true,
..Settings::default()
};
let mips = generate(base, 4, settings);
for level in &mips[3..] {
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
let coverage = passing / level.pixels.len() as f32;
assert!((0.15..0.35).contains(&coverage), "{coverage}");
}
}
}
+265 -18
View File
@@ -1236,6 +1236,9 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
/// first the one the button opens.
NativePackOptions(usize, u8),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1261,6 +1264,8 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1282,6 +1287,12 @@ enum Act {
OpenShaderPackFolder,
/// Open the picked pack's Shader Options page.
ShaderOptions,
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
NativePackOptions(usize),
/// Option `.1` of that pack: its next value.
NativePackOption(usize, usize),
/// Every option of that pack back to its default.
NativePackReset(usize),
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
/// every option back to its default.
ShaderOption(String),
@@ -1411,6 +1422,9 @@ pub enum Event {
OpenShaderOptions,
/// The Shader Options page closed with changes: save them and load the pack again.
ShaderOptions,
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
/// with them).
NativePackOptions(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1514,6 +1528,8 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1559,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1622,23 +1639,18 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
_ => return None,
})
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
/// A list's slot with nothing in it: text takes no clicks.
fn blank_slot(w: f32) -> Widget {
Widget {
x: 0.0,
y: 0.0,
w: 150.0,
w,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
@@ -1647,7 +1659,55 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
},
list: false,
row: 0,
}
}
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
/// else every option two a row.
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
use horizon_shader_api::PageItem;
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let count = pack.options.len().min(pack.values.len());
let (columns, items) = match pack.pages.get(usize::from(at)) {
Some(page) => (usize::from(page.columns), page.items.clone()),
None => (2, (0..count).map(PageItem::Option).collect()),
};
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &PageItem| match *item {
PageItem::Option(o) if o < count => {
let option = &pack.options[o];
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
let page = Page::NativePackOptions(i, p as u8);
button(0.0, 0.0, width, label, Act::Push(page))
}
_ => blank_slot(width),
};
let mut rows = Vec::new();
if at == 0 {
rows.push(Row::Note(pack.title.clone()));
}
rows.extend(
items
.chunks(columns)
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
);
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || blank_slot(150.0);
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
use horizon_shaders::ScreenItem;
@@ -1712,6 +1772,26 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
let pack_text = match &wd.kind {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
} => Some(
&s.shader_packs
.get(*pack)?
.pages
.get(usize::from(*at))?
.description,
),
_ => None,
};
if let Some(text) = pack_text {
return (!text.is_empty()).then(|| Text::literal(text));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
return None;
@@ -2055,6 +2135,14 @@ impl OptionsUi {
_ => None,
}
}
/// The name of a Horizon pack's options page past its first, as the page's title.
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
let Page::NativePackOptions(pack, at @ 1..) = page else {
return None;
};
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
Some(Text::literal(&page.name))
}
fn title(page: Page) -> &'static str {
match page {
Page::Main => "options.title",
@@ -2073,7 +2161,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2084,6 +2172,7 @@ impl OptionsUi {
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -2173,6 +2262,14 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2239,6 +2336,17 @@ impl OptionsUi {
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
None,
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -2502,13 +2610,19 @@ impl OptionsUi {
if s.shader_packs.is_empty() {
return vec![Row::Header("horizon.shader_packs.empty")];
}
let options = s.shader_packs.iter().any(|p| {
p.file == s.horizon.shader_pack
&& p.kind == crate::shader_pack_list::Kind::Classic
});
let options = options.then(|| {
let picked = s
.shader_packs
.iter()
.position(|p| p.file == s.horizon.shader_pack);
let options = picked.and_then(|i| {
let pack = &s.shader_packs[i];
let act = match pack.kind {
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
_ if pack.options.is_empty() => return None,
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
};
let label = tr("horizon.shader_options.open");
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
});
options
.into_iter()
@@ -2526,6 +2640,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -2977,7 +3092,8 @@ impl OptionsUi {
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
text: Self::pack_page_title(s, page)
.unwrap_or_else(|| tr(Self::title(page))),
color: u32::MAX,
centered: true,
},
@@ -3062,6 +3178,9 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3722,6 +3841,25 @@ impl OptionsUi {
options.cycle(&name, true);
events.push(Event::ButtonClick);
}
let native = widgets.iter().find_map(|wd| match wd.kind {
WKind::Button {
active: true,
act: Act::NativePackOption(pack, option),
..
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (y < top || y >= bottom)) =>
{
Some((pack, option))
}
_ => None,
});
if let Some((pack, option)) = native
&& let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, true, false)
{
events.push(Event::ButtonClick);
events.push(Event::NativePackOptions(pack));
}
return events;
}
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
@@ -3838,6 +3976,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, self.shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4745,6 +4899,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -5032,6 +5202,82 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
s.horizon.shader_pack = "p".into();
s.shader_packs = vec![crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
}],
values: vec![1.0],
pages: vec![
horizon_shader_api::OptionPage {
id: "main".into(),
name: "P".into(),
description: String::new(),
columns: 2,
items: vec![horizon_shader_api::PageItem::Page(1)],
},
horizon_shader_api::OptionPage {
id: "more".into(),
name: "More".into(),
description: String::new(),
columns: 1,
items: vec![horizon_shader_api::PageItem::Option(0)],
},
],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
let w = widgets
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
// The option is on the pack's second page, behind a button on its first.
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
ui.shift = false;
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.back(&mut s);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -6568,6 +6814,7 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Agent,
]
);
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -1024,6 +1024,7 @@ impl PostEffects {
size: [u32; 2],
depth: Option<&wgpu::TextureView>,
req: &Request,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
let mut applied = Vec::new();
@@ -1099,6 +1100,7 @@ impl PostEffects {
(&work.1, size),
0.0,
format,
timing.as_deref_mut(),
);
self.staging = Some(staging);
for id in &applied {
@@ -1113,6 +1115,7 @@ impl PostEffects {
depth,
req.menu_blur_radius,
format,
timing.as_deref_mut(),
);
self.chains.insert(id.clone(), Some(chain));
}
@@ -1126,6 +1129,7 @@ impl PostEffects {
(target, size),
0.0,
format,
timing,
);
self.blit = Some(blit);
self.work = Some(work);
@@ -1144,6 +1148,7 @@ impl PostEffects {
main_depth: Option<&wgpu::TextureView>,
menu_radius: f32,
format: wgpu::TextureFormat,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -1203,6 +1208,7 @@ impl PostEffects {
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect depth clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -1221,6 +1227,7 @@ impl PostEffects {
let c = t.clear.map(f64::from);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect target clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &chain.textures[i - 1].1,
resolve_target: None,
@@ -1274,6 +1281,7 @@ impl PostEffects {
&inputs,
(view(pass.output), sizes[pass.output]),
format,
timing.as_deref_mut(),
);
continue;
}
@@ -1287,6 +1295,7 @@ impl PostEffects {
(view(pass.output), sizes[pass.output]),
menu_radius,
format,
timing.as_deref_mut(),
);
}
}
@@ -1303,6 +1312,7 @@ impl PostEffects {
inputs: &[(&wgpu::TextureView, [u32; 2])],
output: (&wgpu::TextureView, [u32; 2]),
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
let Some(pipeline) = translated.pipelines.get(&format) else {
return;
@@ -1353,6 +1363,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1381,6 +1392,7 @@ impl PostEffects {
output: (&wgpu::TextureView, [u32; 2]),
menu_radius: f32,
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
// SamplerInfo: OutSize, then each input's size.
let info = [
@@ -1441,6 +1453,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1715,6 +1728,7 @@ mod tests {
menu_blur_radius: 5.0,
globals: Globals::default(),
},
None,
);
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -1845,6 +1859,7 @@ mod tests {
menu_blur_radius: 0.0,
globals: Globals::default(),
},
None,
));
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
+4 -2
View File
@@ -332,6 +332,7 @@ impl Settings {
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -340,9 +341,10 @@ impl Settings {
.parent()
.map(crate::extras::Extras::load)
.unwrap_or_default();
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
.map(crate::mod_list::ModList::load)
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
Ok(bytes) => Self {
@@ -649,7 +651,7 @@ impl Settings {
self.horizon.save(dir);
self.controller.save(dir);
self.extras.save(dir);
self.mods.save(dir);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
/// Volume of a sound source (master excluded).
@@ -2,7 +2,9 @@
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -14,13 +16,48 @@ pub enum Kind {
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
/// Its options page's `[[pages]]`; empty for one page of every option.
pub pages: Vec<OptionPage>,
}
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
}
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
/// the pack has that option.
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
*v = if reset {
o.kind.default_constant()
} else {
step_value(&o.kind, *v, back)
};
true
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
self.values = defaults.collect();
}
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
@@ -53,11 +90,18 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -68,9 +112,128 @@ fn entry(path: &Path) -> Option<Entry> {
title: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
let mut path = pack.as_os_str().to_owned();
path.push(".txt");
PathBuf::from(path)
}
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
/// out of the option's range is brought into it.
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
let saved: std::collections::HashMap<&str, f64> = text
.lines()
.filter_map(|l| l.split_once('='))
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
.collect();
options
.iter()
.map(|o| {
saved
.get(o.id.as_str())
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
})
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
.zip(values)
.filter(|(o, v)| o.kind.default_constant() != **v)
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
}
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
}
}
/// The option's next value (`back`: the one before), wrapping around at the ends.
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
let dir = if back { -1.0 } else { 1.0 };
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
if !back && v >= max - step * 0.01 {
min
} else if back && v <= min + step * 0.01 {
max
} else {
// On the grid of steps from the minimum, so float error never piles up.
let n = ((v - min) / step).round() + dir;
(min + n * step).clamp(min, max)
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
let n = v.round() + dir;
if n > max {
min
} else if n < min {
max
} else {
n
}
}
OptionKind::Choice { values, .. } => {
let n = values.len() as f64;
(v.round() + dir).rem_euclid(n)
}
}
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
"options.on"
} else {
"options.off"
}),
OptionKind::Float { step, .. } => {
let digits = if *step >= 1.0 {
0
} else if *step >= 0.1 {
1
} else {
2
};
format!("{v:.digits$}")
}
OptionKind::Int { .. } => format!("{v:.0}"),
OptionKind::Choice { values, .. } => {
values.get(v.round() as usize).cloned().unwrap_or_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +269,59 @@ mod tests {
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn values_step_wrap_and_round_trip() {
let float = OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
};
assert_eq!(step_value(&float, 1.0, false), 1.5);
assert_eq!(step_value(&float, 2.0, false), 0.0);
assert_eq!(step_value(&float, 0.0, true), 2.0);
assert_eq!(show_value(&float, 1.5), "1.5");
let choice = OptionKind::Choice {
values: vec!["a".into(), "b".into(), "c".into()],
default: 0,
};
assert_eq!(step_value(&choice, 2.0, false), 0.0);
assert_eq!(step_value(&choice, 0.0, true), 2.0);
assert_eq!(show_value(&choice, 1.0), "b");
let int = OptionKind::Int {
default: 0,
min: -1,
max: 1,
};
assert_eq!(step_value(&int, 1.0, false), -1.0);
let on = OptionKind::Bool { default: true };
assert_eq!(step_value(&on, 1.0, false), 0.0);
let dir =
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pack = dir.join("p.hshader");
let options = [option("a", float), option("b", choice)];
// Only what differs from the defaults is written; out of range comes back clamped.
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
assert_eq!(text, "b=2\n");
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+141
View File
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
/// `texture` id that replaces its skin.
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
nbt_text(profile.get("texture"))
}
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
/// when a head's skin is not downloaded yet.
@@ -467,6 +473,11 @@ pub fn head_skin(
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> String {
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
@@ -495,6 +506,68 @@ pub fn head_skin(
crate::tab_list::default_skin(p.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
/// override (`ClientAsset.ResourceTexture`, a resource pack's
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
/// default skin. Vanilla textures are in the atlas already; another
/// namespace's loads into a skin slot, and `None` answers until it is there
/// (or when the packs lack it).
pub fn patch_skin(
texture: &str,
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> Option<String> {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
if !valid_texture_id(namespace, path) {
return None;
}
if namespace == "minecraft" && atlas(path) {
return Some(path.to_owned());
}
let location = format!("patch/{namespace}/{path}");
if atlas(&location) {
downloads.drawn(&location);
return Some(location);
}
downloads.resource(&location, namespace, path);
None
}
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
/// it): a server's `texture` never names a file outside the packs.
fn valid_texture_id(namespace: &str, path: &str) -> bool {
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
!namespace.is_empty()
&& namespace
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
&& path
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
&& segment(namespace)
&& path.split('/').all(segment)
}
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
/// the nearest of the image's at the same normalised UV.
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let (sx, sy) = (x * w / 64, y * h / 64);
let s = (sy * w + sx) * 4;
let d = (y * 64 + x) * 4;
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
}
}
Some(out)
}
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
pub fn texture_hash(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
@@ -732,6 +805,10 @@ pub struct Downloader {
image_order: std::collections::VecDeque<String>,
/// Skins and capes drawn from the atlas since `take_drawn`.
drawn: std::collections::HashSet<String>,
/// The resource packs' root, where a profile's `texture` patch is read.
resource_root: std::path::PathBuf,
/// How each `patch/` texture of the resource packs loaded, by location.
resources: std::collections::HashMap<String, Download>,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -749,8 +826,48 @@ impl Downloader {
images: Default::default(),
image_order: Default::default(),
drawn: Default::default(),
resource_root: Default::default(),
resources: Default::default(),
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
/// textures load again from there.
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
for location in std::mem::take(&mut self.resources).into_keys() {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
/// from the resource packs into a skin slot as `location`, once. A
/// texture of another size is sampled as the model's normalised UVs would
/// sample it (nearest texel) into the 64x64 slot.
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
match self.resources.get(location) {
Some(Download::Failed) => return,
Some(_) => {
self.again(location);
return;
}
None => {}
}
let file = self
.resource_root
.join(format!("assets/{namespace}/textures/{path}.png"));
let image = std::fs::read(file)
.ok()
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
let state = match image {
Some(rgba) => {
self.queue(location.to_owned(), rgba);
Download::Ready
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
@@ -824,6 +941,15 @@ impl Downloader {
if self.queued.contains(location) {
return true;
}
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
return true;
}
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
if let Some(digest) = location.strip_prefix("skins/") {
(digest, process_legacy_skin)
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
fn a_texture_patch_stays_inside_the_packs() {
use super::valid_texture_id;
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
assert!(!valid_texture_id("ethereal", "../../../secret"));
assert!(!valid_texture_id("..", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity//x"));
assert!(!valid_texture_id("Ethereal", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity\\x"));
assert!(!valid_texture_id("", "entity/x"));
}
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
/// table holds nothing and hands out slots from the start again, even for
/// skins drawn in the last frame.
@@ -25,6 +25,9 @@ pub const UI: usize = 10;
/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static.
pub const STATIC_CHANNELS: usize = 247;
pub const STREAMING_CHANNELS: usize = 8;
/// Static channels only sounds played to the listener (no position) may take
/// while the `uiSoundReserve` extra is on.
pub const HEARD_RESERVE: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayResult {
@@ -65,6 +68,9 @@ pub enum Ticking {
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
/// A native mod's music layer (`MusicLayers`), whose volume its owner
/// sets each tick.
MusicLayer,
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -230,6 +236,25 @@ impl SoundInstance {
true,
)
}
/// A native mod's music layer: a looping, unattenuated track on the music
/// source that starts silent (`MusicLayers` fades it in).
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
let mut s = Self::new(
id,
MUSIC,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
true,
);
s.can_start_silent = true;
s.ticking = Ticking::MusicLayer;
s
}
/// `SimpleSoundInstance.forJukeboxSong`.
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
Self::new(
@@ -519,7 +544,7 @@ impl SoundInstance {
/// `TickableSoundInstance.tick`.
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No => {}
Ticking::No | Ticking::MusicLayer => {}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -792,6 +817,9 @@ pub struct SoundEngine {
pub source_volumes: [f32; 11],
/// A `SoundEventListener` is registered.
pub listening: bool,
/// Static channels positional sounds leave free for the ones played to the
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
@@ -819,6 +847,7 @@ impl SoundEngine {
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
source_volumes: [1.0; 11],
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
}
}
@@ -1100,7 +1129,15 @@ impl SoundEngine {
let looping = instance.looping && instance.delay <= 0;
let streaming = sound.stream;
let pool = usize::from(streaming);
let channel = if self.used[pool] >= self.limits[pool] {
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
// them (a crater's worth of blocks breaking) can't stop the ones played to the listener.
let positional = !instance.relative && instance.attenuation != Attenuation::None;
let reserved = if positional && !streaming {
self.listener_reserve
} else {
0
};
let channel = if self.used[pool] + reserved >= self.limits[pool] {
None
} else {
backend.create(streaming)
@@ -1549,4 +1586,47 @@ mod tests {
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
}
#[test]
fn world_sounds_leave_channels_for_the_listener_only_with_the_extra() {
use test_backend::Null;
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"block.stone.break": {"sounds": ["dig/stone1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
let random = || LegacyRandom::new(0);
let block = |i: usize| {
let at = [i as f64, 64.0, 0.0];
let source = crate::settings::sound_source("minecraft:block.stone.break");
SoundInstance::at(
"minecraft:block.stone.break",
source,
1.0,
1.0,
random(),
at,
)
};
let click = || SoundInstance::for_ui("minecraft:ui.button.click", 1.0, 0.25, random());
for reserve in [0, HEARD_RESERVE] {
let mut engine = SoundEngine::new(registry.clone());
engine.listener_reserve = reserve;
let mut backend = Null::default();
// Positional sounds take the static channels the reserve leaves; then their plays fail.
let started = (0..STATIC_CHANNELS)
.filter(|&i| {
engine.play_new(block(i), false, &mut backend).1 == PlayResult::Started
})
.count();
assert_eq!(started, STATIC_CHANNELS - reserve);
let heard = engine.play_new(click(), false, &mut backend).1;
assert_eq!(
heard == PlayResult::Started,
reserve > 0,
"reserve {reserve}"
);
}
}
}
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
+288 -42
View File
@@ -85,18 +85,93 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Still textures by their pixels and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
pub specular: Vec<u8>,
pub pbr_size: u32,
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
/// Largest sprite array (all layers of the top mip level): resource packs
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
/// Horizon: resource packs with bigger textures are sampled down to fit.
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
/// resources loaded from now on.
pub fn set_texture_budget(mib: u32) {
let bytes = match mib {
0 => DEFAULT_TEXTURE_BUDGET,
mib => u64::from(mib) << 20,
};
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
/// square RGBA layers take at most `bytes`.
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
size /= 2;
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
@@ -156,7 +231,7 @@ impl Sprites {
}
/// Lay every layer out at one size: the largest texture's (resource packs
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
@@ -170,13 +245,18 @@ impl Sprites {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
let size = side_within(largest, layers, budget);
if pixels {
log::info!(
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
budget >> 20
);
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.pbr = Vec::new();
self.size = size;
return;
}
@@ -192,6 +272,30 @@ impl Sprites {
animation.frames = resized[animation.layer as usize].clone();
}
self.size = size;
self.normals.clear();
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
}
}
}
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
@@ -222,6 +326,7 @@ impl Sprites {
.collect();
let layer = u32::try_from(self.native.len())?;
self.opaque.push(true);
self.pbr.push([None, None]);
self.translucent.push(false);
self.mips.push(crate::mipmap::Settings::default());
self.layers.insert(name.to_owned(), layer);
@@ -242,12 +347,28 @@ impl Sprites {
decoded
}
};
self.register(name, decoded)
self.register(root, name, decoded)
}
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
/// that does not load is an error: the caller drops what needed it. Such files bypass the
/// disk cache, whose fingerprint covers only `textures/`.
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = Self::decode_file(&root.join(resource), resource)?;
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
@@ -263,40 +384,19 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let (png_width, png_height, pixels) = read_rgba(path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
(u32::try_from(width)?, u32::try_from(height)?)
} else {
(info.width, info.height)
(png_width, png_height)
};
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
return Err(format!("sprite {name} has invalid frame dimensions").into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
let columns = info.width / width;
let count = columns * (info.height / height);
let columns = png_width / width;
let count = columns * (png_height / height);
// Layers are square; a frame of another shape is nearest-resampled to
// its longer side so UVs (normalized per layer) are unchanged.
let side = width.max(height);
@@ -309,7 +409,7 @@ impl Sprites {
let source_row = y + row * height / side;
for column in 0..side {
let offset =
((source_row * info.width + x + column * width / side) * 4) as usize;
((source_row * png_width + x + column * width / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
@@ -339,7 +439,12 @@ impl Sprites {
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
fn register(
&mut self,
root: &Path,
name: &str,
decoded: crate::sprite_cache::Decoded,
) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
@@ -356,6 +461,13 @@ impl Sprites {
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
};
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
@@ -364,10 +476,15 @@ impl Sprites {
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
side,
[
settings.strategy as u32,
@@ -401,12 +518,66 @@ impl Sprites {
self.mips.push(settings);
self.opaque.push(opaque);
self.translucent.push(translucent);
self.pbr.push(pbr);
self.layers.insert(name.to_owned(), layer);
self.native.push((side, frames, initial));
Ok(layer)
}
}
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -452,6 +623,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
mod tests {
use super::*;
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
// Never under vanilla's size, whatever the budget.
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
[200, 100, 0, 255],
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[None, None],
],
..Default::default()
};
sprites.finish();
assert_eq!(sprites.pbr_size, 16);
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
assert_eq!(sprites.specular[..4], NO_SPECULAR);
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
pbr: vec![[None, None]],
..Default::default()
};
sprites.finish();
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
@@ -459,6 +678,33 @@ mod tests {
}
}
#[test]
fn files_outside_textures_load_by_path() {
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let mut sprites = Sprites::default();
let layer = sprites.insert_file(&root, tile).unwrap();
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
assert!(sprites.translucent[layer as usize]);
// A missing tile is an error, not the missing texture.
assert!(
sprites
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
.is_err()
);
assert_eq!(sprites.count(), 1);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
+79 -22
View File
@@ -6,6 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
@@ -149,6 +150,8 @@ pub trait LodSampler: Send {
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
self.column(x, z)
}
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
/// when sampling failed and should be tried again later.
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
let mut columns = Vec::with_capacity(side * side);
for dz in 0..side {
@@ -202,6 +205,7 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -215,6 +219,8 @@ enum Job {
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
/// A proto chunk checked out for its light view (`light_region`).
LightView(i32, i32, Proto),
/// Meshes the renderer is done with (`Streaming::free`): their memory
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
@@ -235,6 +241,8 @@ pub enum Done {
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
/// A proto chunk back from `Job::LightView`, with its view.
LightView(i32, i32, Proto, Option<Chunk>),
}
struct Queued {
priority: i64,
@@ -279,6 +287,8 @@ struct Shared {
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -352,7 +362,6 @@ pub struct Streaming {
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
@@ -377,6 +386,8 @@ pub struct Streaming {
decorating_finals: HashSet<(i32, i32)>,
/// Save jobs in flight.
saving: usize,
/// Proto chunks checked out to a worker for their light view.
viewing: FxHashSet<(i32, i32)>,
/// Vanilla's decoration order around the player, and the centre and
/// radius it was last ranked for.
decoration_order: DecorationOrder,
@@ -429,6 +440,8 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
light_tables: Arc<LightTables>,
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
@@ -618,8 +631,8 @@ impl Streaming {
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -673,7 +686,7 @@ impl Streaming {
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
@@ -732,6 +745,10 @@ impl Streaming {
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
@@ -759,6 +776,7 @@ impl Streaming {
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -780,20 +798,22 @@ impl Streaming {
shared.done.send(done);
}
})
.expect("spawn worker")
})
.collect();
let light_view = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
@@ -808,6 +828,7 @@ impl Streaming {
decorating: 0,
decorating_finals: HashSet::new(),
saving: 0,
viewing: Default::default(),
decoration_order: DecorationOrder::default(),
order_area: None,
queue_basis: Default::default(),
@@ -837,6 +858,7 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
light_edit_sequence: 0,
@@ -1092,28 +1114,42 @@ impl Streaming {
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
/// A proto neighbour without its view yet is checked out to a worker,
/// which builds the view off the server thread (`Job::LightView`); the
/// region is asked for again once it is back.
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
let mut complete = true;
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
region[dz][dx] = match world.chunks.get(&n) {
Some(chunk) => Some(chunk.clone()),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
if let Some(view) = self.light_views.get(&n) {
view.clone()
Some(view.clone())
} else {
let proto = self.protos.get(&n)?.as_ref()?;
let view = Arc::new((self.light_view)(proto)?);
self.light_views.insert(n, view.clone());
view
let proto = self.protos.get_mut(&n)?.take()?;
self.viewing.insert(n);
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
complete = false;
None
}
}
None => return None,
});
};
}
}
Some(Box::new(region))
complete.then(|| Box::new(region))
}
/// A proto chunk back from its light view: the view waits in
/// `light_views` for the light jobs that asked for it.
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
self.viewing.remove(&(x, z));
self.protos.insert((x, z), Some(proto));
if let Some(view) = view {
self.light_views.insert((x, z), Arc::new(view));
}
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
@@ -1201,6 +1237,7 @@ impl Streaming {
pub fn busy(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
@@ -1264,6 +1301,7 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
Done::Lit(x, z, sections, seq) => {
if self.lighting.get(&(x, z)) != Some(&seq) {
continue;
@@ -1680,6 +1718,7 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
leaf_culling: self.leaf_culling,
},
),
);
@@ -1822,7 +1861,11 @@ impl Streaming {
return;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
while self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region, finals, writes)) => {
@@ -1830,6 +1873,9 @@ impl Streaming {
}
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(Done::LightView(x, z, proto, view)) => {
self.receive_light_view(x, z, proto, view)
}
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
@@ -1860,13 +1906,18 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
other => self.deferred.push(other),
}
if started.elapsed() >= std::time::Duration::from_millis(2) {
return false;
}
}
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
if self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
return false;
}
let written: Vec<_> = self.written.drain().collect();
@@ -1940,7 +1991,11 @@ impl Streaming {
counts[1] += 1;
} else {
counts[2] += 1;
if self.light_region(world, key.0, key.1).is_some() {
if self.light_region(world, key.0, key.1).is_some()
|| (-1..=1).any(|dz| {
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
})
{
world.dirty_light.insert(key);
}
}
@@ -2325,6 +2380,7 @@ mod tests {
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
mesh.block_entities.push(([1, 2, 3], 7));
mesh.lights.push(([1, 2, 3], 7));
mesh.visibility = 0;
mesh.sides = crate::mesher::Sides::unsorted(16);
let capacities = mesh.capacity_bytes();
@@ -2341,6 +2397,7 @@ mod tests {
assert!(reused.solid.is_empty());
assert!(reused.translucent.is_empty());
assert!(reused.block_entities.is_empty());
assert!(reused.lights.is_empty());
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
assert_eq!(reused.sides, crate::mesher::Sides::default());
assert_eq!(pool.bytes, 0);
+85 -6
View File
@@ -92,19 +92,29 @@ fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
// `tiled`: a distant tile's face takes its sprite again per block, so its gradients span many
// sprites while `uv` stays inside one (see `tile_uv` below).
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
let at = (center + offset) * pixel;
if tiled {
// An anisotropic sampler lays its taps along those gradients, past the sprite's edge,
// where the clamped sampler reads that edge texel again: long streaks on the far tiles.
// Asking for the level the gradients give takes the one tap an anisotropy of 1 would.
let level = max(length(du), length(dv)) / min(pixel.x, pixel.y);
return sprite_level(at, layer, max(0.0, log2(level)));
}
return sprite_grad(at, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
@@ -124,7 +134,7 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -137,12 +147,15 @@ fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
let du = dpdx(tiled);
let dv = dpdy(tiled);
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
return sample_rgss(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
struct TerrainOut {
@@ -295,6 +308,56 @@ fn fade_code(w: f32) -> u32 {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid terrain draws in two passes from the same draws: first the quads with no
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
}
fn left_out() -> TerrainOut {
var out: TerrainOut;
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
@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);
}
@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);
}
@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 {
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);
}
@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 {
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);
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
let pos = position + origin;
@@ -344,6 +407,13 @@ fn brightness(c: vec3<f32>) -> f32 {
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
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> {
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);
return solid_finish(in, texel, color);
}
// 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> {
@@ -360,6 +430,10 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
// terrain.fsh: the fade-in mixes toward the fog colour.
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return solid_finish(in, texel, color);
}
// 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> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
// brightest. Only pixels as bright as their sprite's threshold may: its
@@ -517,6 +591,11 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
// 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.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
let dist = disc_distance(d, -4.0);
@@ -98,6 +98,25 @@ thread_local! {
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
/// runs on this thread ([`take`] and [`put`] around each turn).
#[derive(Default)]
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
pub fn target(rect: [f32; 4]) {
target_as(rect, Kind::Button);
+8
View File
@@ -36,6 +36,7 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -83,6 +84,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
@@ -34,6 +34,8 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
@@ -41,6 +43,7 @@
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
@@ -48,6 +51,7 @@
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
"horizon.pages.shader_options": "Opciones de sombreadores",
"horizon.pages.split_profiles": "¿Quién juega?",
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
}
@@ -18,5 +18,11 @@
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
}
@@ -202,5 +202,11 @@
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
"horizon.pages.shader_options": "Настройки шейдеров",
"horizon.pages.split_profiles": "Кто играет?",
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
}
@@ -196,5 +196,11 @@
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
"horizon.pages.split_profiles": "Kim oynuyor?",
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
}
@@ -18,5 +18,11 @@
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
}
@@ -203,5 +203,11 @@
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
"horizon.pages.shader_options": "光影选项",
"horizon.pages.split_profiles": "谁在玩?",
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…"
}
+9
View File
@@ -5,6 +5,15 @@ fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
@@ -17,6 +17,7 @@ use horizon_client::{
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
graphics::LeafCullingMode,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
@@ -139,6 +140,7 @@ fn main() -> Result<(), Error> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
@@ -133,7 +133,7 @@ fn main() -> Result<()> {
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
properties: properties.into(),
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
// The fixture's mobs keep no whole stacks.
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
if let Some(n) = c["scroll"].as_i64() {
chat.scroll_by(n as i32, focused);
}
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
}
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
@@ -1182,6 +1182,7 @@ pub fn main() -> horizon_client::Result<()> {
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
@@ -1315,7 +1316,7 @@ pub fn main() -> horizon_client::Result<()> {
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
{
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
}
Loaded 100 of 607 files, more files were not shown because too many files have changed in this diff. Show more