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 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
[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
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
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
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
[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
[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
[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
[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 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 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 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
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
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
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 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 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 144ce26f37 Merge main into #5771
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:49:46 +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 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 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 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
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 Marie 90f08d3fbb Merge #5900 into the shader API union 2026-11-01 14:27:09 +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
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
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
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
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
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
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
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
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
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
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
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
levzzz fab6784831 Merge remote-tracking branch 'hr/main' into pr-5731
# Conflicts:
#	docs/native-mods.md
2026-11-01 16:06:56 +05:00
levzzz f10724e8b5 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:48:10 +05: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
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
levzzz 4359398e57 Merge remote-tracking branch 'hr/main' into pr-5731 2026-11-01 15:10:25 +05: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
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-O 764cbf8214 Merge main into #5731 2026-11-01 08:00:13 +00: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
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
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
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 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
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
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
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 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
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-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
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
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 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
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
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
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 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
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
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 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
hasanbkaylan 65c21a8463 Update tr.json 2026-11-01 07:24:29 +03: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
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
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
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 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
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 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-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
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
ElyTheCatandAntigravity 4ca447371b Merge origin/main into feat/leaf-culling
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 07:00:49 +03:30
ElyTheCatandAntigravity 4af3055742 Add configurable leaf culling (Hollow, Solid, SolidAggressive)
Co-Authored-By: Antigravity <[email protected]>
2026-11-01 06:59:23 +03:30
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
207 changed files with 17156 additions and 602 deletions

No files matched your search

Generated
+2
View File
@@ -1395,6 +1395,7 @@ dependencies = [
"horizon-hdr",
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
@@ -1516,6 +1517,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"log",
"regex",
]
+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.
+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,
}
+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" }
+30 -2
View File
@@ -101,6 +101,8 @@ pub struct RenderAssets {
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)
@@ -240,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();
@@ -267,6 +294,7 @@ impl RenderAssets {
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
})
}
}
+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() {
+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);
}
}
@@ -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"
);
}
}
+24
View File
@@ -407,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>()
+34 -2
View File
@@ -111,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
@@ -152,6 +156,8 @@ impl Default for Extras {
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,
@@ -230,6 +236,8 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
"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),
@@ -241,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:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\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),
@@ -274,6 +282,8 @@ impl Extras {
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),
@@ -406,6 +416,10 @@ pub enum Item {
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,
@@ -430,6 +444,9 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
@@ -449,6 +466,8 @@ impl Item {
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",
@@ -462,8 +481,10 @@ impl Item {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
@@ -499,6 +520,10 @@ 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."
}
@@ -520,6 +545,7 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
@@ -541,6 +567,8 @@ impl Item {
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,
@@ -571,6 +599,8 @@ impl Item {
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,
@@ -633,6 +663,8 @@ mod tests {
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);
+265 -32
View File
@@ -142,6 +142,7 @@ pub mod panorama;
mod readback;
mod resident;
mod stars;
pub mod submit;
mod sunrise_fan;
mod timing;
pub mod translucency;
@@ -164,6 +165,50 @@ fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
/// depth loaded, and its `surface` target, cleared on the first of these passes.
fn surface_pass<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
surface: &wgpu::TextureView,
depth: &wgpu::TextureView,
clear: bool,
) -> wgpu::RenderPass<'e> {
let keep = |load| wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon world, pack terrain"),
color_attachments: &[
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: keep(wgpu::LoadOp::Load),
}),
Some(wgpu::RenderPassColorAttachment {
view: surface,
resolve_target: None,
depth_slice: None,
ops: keep(if clear {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
wgpu::LoadOp::Load
}),
}),
],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
@@ -618,7 +663,11 @@ pub struct Renderer {
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
/// Frames are finished and submitted on its submit thread ([`submit`]).
pub queue: submit::Queue,
/// The last frame went to the submit thread; whether it had no window surface.
/// The next frame takes it in ([`Self::land_frame`]).
deferred: Option<bool>,
/// Set by the device's lost callback. wgpu reports nothing else when an
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
@@ -640,6 +689,9 @@ pub struct Renderer {
sides_on: bool,
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
depth_reset: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
@@ -2039,6 +2091,40 @@ impl Renderer {
false,
Some(alpha_blend),
);
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand depth reset"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_sky"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_depth_reset"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
});
let lines = pipeline(
"Block outline",
"vs_line",
@@ -3358,7 +3444,8 @@ impl Renderer {
last_encode: std::time::Duration::ZERO,
window,
device,
queue,
queue: submit::Queue::new(queue, submit::wanted()),
deferred: None,
device_lost,
config,
solid,
@@ -3372,6 +3459,7 @@ impl Renderer {
flat,
sky,
dark_disc,
depth_reset,
lines,
lines_secondary,
debug_lines,
@@ -3531,6 +3619,14 @@ impl Renderer {
present_modes,
})
}
/// The first-person hand's quads, which follow `first` quads of the entity buffer.
fn draw_hand(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32, first: u32) {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -3578,6 +3674,7 @@ impl Renderer {
/// Hand the window surface over to the renderer that replaces this one on
/// the same device (it takes `spare_surface`).
pub fn release_surface(&mut self) {
self.queue.wait();
*self
.spare_surface
.get_mut()
@@ -3585,6 +3682,7 @@ impl Renderer {
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
self.queue.wait();
if self.offscreen.is_some() {
return Ok(());
}
@@ -4305,6 +4403,8 @@ impl Renderer {
/// Configures the window surface again. One that fails is still the one
/// wgpu configured before, which acquisition reports as a validation error.
fn configure_surface(&self) {
// The submit thread may still present to the old swapchain.
self.queue.wait();
if let Some(surface) = &self.surface
&& let Err(e) = Self::configure(
&self.device,
@@ -4944,6 +5044,7 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.land_frame();
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
@@ -6191,6 +6292,14 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.bind_depth(&self.device, world_depth);
}
// The hand draws at the end of the world pass, after a depth reset, when nothing runs
// between them (a scaled world's post pass, HDR's world copy, a native mod's
// `native_world_drawn`): one render pass fewer, ~10 µs of wgpu's encoding a frame.
let hand_in_world = (frame.hand.len() / 4) > 0
&& !pack_draws_hand
&& scaled.is_none()
&& self.hdr.is_none()
&& !native_render::world_drawn_hooked();
if oit_on {
let size = self
.world_post
@@ -6519,6 +6628,16 @@ impl Renderer {
.frame
.fit_opaque(&self.device, world_depth.texture(), color);
}
if let Some(native) = self.native.as_mut().filter(|_| !frame.title) {
let writes = native.material.as_ref().is_some_and(|m| m.writes_surface);
let depth = world_depth.texture();
let size = if writes {
[depth.width(), depth.height()]
} else {
[1, 1]
};
native.frame.fit_surface(&self.device, size);
}
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
// which read the pack's frame at group 3.
let material = self
@@ -6541,6 +6660,17 @@ impl Renderer {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
// passes of its own between the sky and everything else.
let surface = self
.native
.as_ref()
.filter(|n| {
!frame.title
&& !pack_draws_terrain
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
})
.map(|n| n.frame.surface_view.clone());
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: world_view,
@@ -6626,6 +6756,28 @@ impl Renderer {
pass.draw(0..3, 0..1);
}
}
let terrain_pass =
|encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, clear: bool| {
// Its lifetime forgotten: a closure cannot tie it to `encoder`'s.
let mut pass = surface_pass(encoder, world_view, surface, world_depth, clear)
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
// The occlusion test's pipeline draws into the world's colour alone.
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
let mut pass =
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
.forget_lifetime();
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass
};
if let Some(surface) = &surface {
drop(pass);
pass = terrain_pass(&mut encoder, surface, true);
}
match self
.flat
.as_ref()
@@ -6654,18 +6806,25 @@ impl Renderer {
// that the test saw, copied after it.
Some(cull) => {
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for &pipeline in passes {
@@ -6697,17 +6856,24 @@ impl Renderer {
pass.set_pipeline(pipeline);
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
}
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder, self.own_timing.as_mut());
pass = world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
);
pass = match &surface {
Some(surface) => terrain_pass(&mut encoder, surface, false),
None => world_pass_after_culling(
&mut encoder,
world_view,
world_depth,
keep_depth,
self.own_timing.as_mut(),
"World after culling",
),
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
pass.set_index_buffer(
@@ -6735,6 +6901,11 @@ impl Renderer {
}
}
}
if surface.is_some() {
drop(pass);
pass = plain_pass(&mut encoder);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
if entity_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -7197,6 +7368,12 @@ impl Renderer {
0..1,
);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..3, 0..1);
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
}
}
if let Some((color, depth)) = &scaled
&& pack_post
@@ -7317,7 +7494,7 @@ impl Renderer {
post.run(
&self.device,
&mut encoder,
(color, depth),
(color, depth, &native.frame.surface_view),
&view_target,
size,
history_reset,
@@ -7358,7 +7535,7 @@ impl Renderer {
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
}
if hand_quads > 0 && !pack_draws_hand {
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
@@ -7382,15 +7559,7 @@ impl Renderer {
}),
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..hand_quads * 6,
((world_quads + additive_quads) * 4) as i32,
0..1,
);
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
@@ -7578,15 +7747,54 @@ impl Renderer {
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
let commands = crate::spans::time("encode", || encoder.finish());
let first_frame = self.first_frame == FirstFrame::Submit;
// Finished, submitted and presented on the submit thread when nothing after the
// submission needs this one: screenshots' readbacks, the error scopes (one per
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
let (encoder, surface_frame, after) = if readback.is_none()
&& hdr_readback.is_none()
&& checks.is_none()
&& !first_frame
&& !reconfigure
&& self.hdr_marker.is_none()
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
{
let offscreen = surface_frame.is_none();
// Culling reads its results back as soon as the frame is submitted, as here.
let after = self
.cull
.as_mut()
.and_then(|c| c.submitted())
.map(|f| Box::new(f) as submit::AfterSubmit);
let job = submit::Job {
encoder,
surface: surface_frame,
after,
};
match self.queue.submit_later(job) {
Ok(()) => {
self.deferred = Some(offscreen);
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
self.visible = visible;
self.visible_lods = visible_lods;
self.shadow_visible = shadow_visible;
return Ok(true);
}
Err(back) => (back.encoder, back.surface, back.after),
}
} else {
(encoder, surface_frame, None)
};
let commands = crate::spans::time("encode", || encoder.finish());
if first_frame {
startup_step(format_args!("submitting the first frame"));
self.first_frame = FirstFrame::Drawn;
}
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
if let Some(cull) = &mut self.cull {
cull.submitted();
if let Some(read) = after {
read();
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
read();
}
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
@@ -7734,8 +7942,33 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
fn land_frame(&mut self) {
let Some(offscreen) = self.deferred.take() else {
return;
};
let submission = self.queue.landed();
if let Some(timer) = &mut self.gpu_timer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if offscreen && let Some(submission) = submission {
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_frames();
}
}
}
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
self.land_frame();
self.wait_frames();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
+15 -13
View File
@@ -585,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(
@@ -139,17 +139,32 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
// 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),
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_material_fogged(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);
@@ -189,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
@@ -220,6 +237,10 @@ fn layer_table(name: &str, table: &[(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}",
@@ -250,6 +271,7 @@ impl NativeMaterial {
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
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 = [
@@ -296,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),
@@ -321,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,
@@ -370,6 +404,7 @@ impl NativeMaterial {
solid,
translucent,
flat,
writes_surface,
})
}
}
@@ -14,7 +14,7 @@ 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)]
@@ -103,6 +103,9 @@ 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,
@@ -270,6 +273,8 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
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(
@@ -332,6 +337,8 @@ impl NativeFrame {
buffer,
layout,
group,
surface,
surface_view,
opaque_depth,
opaque_color,
reflection: blank.clone(),
@@ -468,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>) {
@@ -573,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,
@@ -652,6 +685,7 @@ impl NativePost {
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
@@ -757,7 +791,7 @@ 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,
) {
@@ -830,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;
@@ -893,6 +930,10 @@ impl NativePost {
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),
@@ -920,7 +961,11 @@ impl NativePost {
})
})
.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
@@ -929,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,
@@ -1349,7 +1394,7 @@ mod tests {
post.run(
&device,
&mut encoder,
(&color, &depth),
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
@@ -1456,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,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();
}
}
}
+252 -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)]
@@ -361,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 {
@@ -371,8 +507,12 @@ 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.
@@ -445,7 +585,8 @@ 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,
@@ -514,7 +655,8 @@ 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" => {
@@ -580,12 +722,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\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",
"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,
@@ -741,7 +884,8 @@ 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,
@@ -772,6 +916,21 @@ mod tests {
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);
@@ -801,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 {
+121 -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,6 +30,7 @@ pub enum H {
FarDetail,
CaveCulling,
EntityCulling,
LeafCulling,
ChunkUpdates,
UploadBudget,
Sky,
@@ -187,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,
@@ -195,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(
@@ -446,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,
}
}
@@ -515,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])),
@@ -648,7 +669,12 @@ 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,
@@ -736,7 +762,6 @@ 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,
@@ -1826,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);
}
}
@@ -1847,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(
@@ -1861,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]
+37 -7
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
@@ -567,15 +570,41 @@ 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.
@@ -777,6 +806,7 @@ impl HudAtlas {
glint,
glint_armor,
text_shader: None,
random_textures,
font,
white,
})
+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;
+53 -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::{
@@ -470,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,
@@ -647,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,
@@ -686,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`.
@@ -860,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;
@@ -1079,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).
@@ -1108,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,
@@ -1172,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(
@@ -1203,6 +1241,7 @@ impl Mesher<'_> {
face,
ao,
state,
interior_leaf,
);
}
}
@@ -1241,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
@@ -1275,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 {
@@ -1285,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),
@@ -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,
@@ -68,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)]
@@ -233,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(
@@ -522,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;
+45 -2
View File
@@ -349,10 +349,26 @@ impl Sprites {
};
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>>,
@@ -368,7 +384,7 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
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(png_width)?, i32::try_from(png_height)?);
@@ -662,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()));
@@ -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,
@@ -204,6 +205,7 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
leaf_culling: LeafCullingMode,
}
enum Job {
Generate(i32, i32),
@@ -438,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.
@@ -772,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,
@@ -853,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,
@@ -1712,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,
},
),
);
@@ -591,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);
+1 -1
View File
@@ -16,7 +16,6 @@ horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
@@ -37,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"
@@ -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": "正在读取光影包的选项…"
}
@@ -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,
@@ -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)
}
@@ -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,
@@ -7,6 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
graphics::LeafCullingMode,
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
@@ -137,6 +138,7 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
leaf_culling: LeafCullingMode::Off,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
+11 -6
View File
@@ -2,6 +2,7 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
@@ -30,7 +31,9 @@ pub struct Display {
pub struct Criterion {
pub name: String,
pub trigger: String,
pub conditions: Value,
/// Read on first test: ~1,300 recipe advancements' criteria, most never
/// tested in a game, took ~4 MB as `Value`s.
pub conditions: LazyJson,
}
#[derive(Clone, Debug, Default)]
pub struct Rewards {
@@ -154,7 +157,8 @@ impl Tree {
.into_iter()
.flatten()
{
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
// `read`: the index alone keeps none of the criteria read.
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
@@ -208,7 +212,8 @@ impl InventoryIndex {
return index;
};
for &(ai, ci) in list {
let items = &tree.list[ai].criteria[ci].conditions["items"];
let conditions = tree.list[ai].criteria[ci].conditions.read();
let items = &conditions["items"];
// `id_matches`: only a string or a list limits the items.
let limited = items.as_array().and_then(|a| {
a.iter()
@@ -266,7 +271,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
.map(|(name, c)| Criterion {
name: name.clone(),
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
conditions: c["conditions"].clone(),
conditions: LazyJson::new(&c["conditions"]),
})
.collect();
let requirements = match v["requirements"].as_array() {
@@ -1606,7 +1611,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:inventory_changed".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
@@ -1655,7 +1660,7 @@ mod tests {
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:recipe_unlocked".into(),
conditions,
conditions: LazyJson::new(&conditions),
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
+36 -9
View File
@@ -1219,6 +1219,15 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
pub fn recipe_id(name: &str) -> String {
if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
}
}
fn custom_type(v: &Value) -> Option<&str> {
v["type"]
.as_str()
@@ -1309,10 +1318,14 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
let sources = Self::sources(root, overlay)?;
Self::from_sources(root, items, overlay, sources)
}
/// The recipe files the tables are built from, each id once: vanilla's by
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
/// over them (`overlay`), parsed, and only those whose load conditions
/// hold. A server sends some of them to its clients (`recipe_content`).
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
@@ -1357,6 +1370,7 @@ impl Recipes {
}
}
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
let mut parsed = Vec::with_capacity(sources.len());
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
@@ -1375,13 +1389,26 @@ impl Recipes {
continue;
}
// The data's recipe filters, over the final set.
let full_id = match name.contains(':') {
true => name.clone(),
false => format!("minecraft:{name}"),
};
if !filters.apply(&full_id, &mut v) {
if !filters.apply(&recipe_id(&name), &mut v) {
continue;
}
parsed.push((name, v));
}
Ok(parsed)
}
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
/// `overlay` give the enchantment tags the tables keep.
pub fn from_sources(
root: &Path,
items: &Items,
overlay: &PackOverlay,
sources: Vec<(String, Value)>,
) -> Result<Self> {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
for (name, v) in sources {
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
+237 -6
View File
@@ -201,6 +201,7 @@ impl ItemEntity {
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
entity_boxes: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -241,10 +242,20 @@ impl ItemEntity {
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else {
} else if entity_boxes.is_empty() {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still).
crate::collisions::EntityCollisions {
blocks: scene,
entities: entity_boxes,
border: None,
}
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
@@ -405,6 +416,71 @@ impl ItemEntity {
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
}
}
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
/// many vehicles checks a handful of boxes per item, not every box in the level.
#[derive(Default)]
pub struct ObstacleGrid {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
boxes: Vec<Aabb>,
}
impl ObstacleGrid {
const CELL: f64 = 8.0;
pub fn new(boxes: Vec<Aabb>) -> Self {
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
for (i, b) in boxes.iter().enumerate() {
let (lo, hi) = Self::span(*b);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Self { cells, boxes }
}
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
let cell = |v: Vec3| {
(
(v.x / Self::CELL).floor() as i32,
(v.y / Self::CELL).floor() as i32,
(v.z / Self::CELL).floor() as i32,
)
};
(cell(b.min_position()), cell(b.max_position()))
}
pub fn is_empty(&self) -> bool {
self.boxes.is_empty()
}
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
out.clear();
if self.boxes.is_empty() {
return;
}
let (lo, hi) = Self::span(area);
let mut found: Vec<u32> = Vec::new();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
found.extend(list);
}
}
}
}
found.sort_unstable();
found.dedup();
out.extend(
found
.into_iter()
.map(|i| self.boxes[i as usize])
.filter(|b| b.intersects(area)),
);
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
@@ -1081,6 +1157,12 @@ pub struct ArmorStand {
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
pub enchantment_block: Option<BlockPos>,
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
pub enchantment_fall_distance: f64,
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
pub enchantment_equipment: [Option<ItemStack>; 6],
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1152,6 +1234,9 @@ impl ArmorStand {
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
enchantment_block: None,
enchantment_fall_distance: 0.0,
enchantment_equipment: Default::default(),
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
@@ -1721,7 +1806,15 @@ mod honey_slide_tests {
item.id = 1;
let mut ys = Vec::new();
for _ in 0..8 {
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
ys.push(item.velocity.y);
}
// Idle ticks keep gravity; the 4th tick's move hits the floor and
@@ -1777,7 +1870,15 @@ mod honey_slide_tests {
item.id = 1;
let before = item.position.y;
for _ in 0..16 {
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
}
assert!(
item.position.y > before + 0.5,
@@ -1787,7 +1888,7 @@ mod honey_slide_tests {
);
}
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
id: 1,
@@ -1995,7 +2096,15 @@ mod slime_bounce_tests {
falling.on_ground = false;
falling.age = 0;
falling.id = 1;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
assert!(falling.on_ground);
@@ -2005,7 +2114,15 @@ mod slime_bounce_tests {
// does not set needs_sync.
falling.velocity = Vec3::ZERO;
falling.needs_sync = false;
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
falling.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
assert!(!falling.needs_sync);
}
}
@@ -2060,3 +2177,117 @@ mod unstuck_tests {
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
}
}
#[cfg(test)]
mod entity_collision_tests {
use super::honey_slide_tests::falling_item;
use super::*;
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
/// where with no box it falls to the floor of the scene.
#[test]
fn an_item_lands_on_an_entity_box_under_it() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
world.set(20, 3, 20, stone).unwrap();
let rest = |boxes: &[Aabb]| {
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
item.id = 1;
for _ in 0..80 {
item.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
boxes,
);
}
item.position.y
};
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
assert!(
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
"on top of the box, at its top"
);
}
#[test]
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
// A boat every 100 blocks along a line, 1000 of them.
let boat = |x: f64| {
Aabb::new(
Vec3::new(x, 64.0, 0.0),
Vec3::new(x + 1.375, 64.5625, 1.375),
)
};
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
let mut out = Vec::new();
// Around the 7th boat: only it.
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![boat(700.0)]);
// Between two boats: none.
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
grid.near(area, &mut out);
assert!(out.is_empty());
// A box spanning cells is found from any of them, once.
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
let grid = ObstacleGrid::new(vec![wide]);
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
grid.near(area, &mut out);
assert_eq!(out, vec![wide]);
}
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
#[test]
#[ignore = "timing"]
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
let boat = |i: u32| {
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
Aabb::new(
Vec3::new(x, 64.0, z),
Vec3::new(x + 1.375, 64.5625, z + 1.375),
)
};
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
let items: Vec<Aabb> = (0..5000)
.map(|i| {
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
})
.collect();
let start = std::time::Instant::now();
let mut hits = 0;
for item in &items {
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
hits += boxes.iter().filter(|b| b.intersects(area)).count();
}
let scan = start.elapsed();
let grid = ObstacleGrid::new(boxes);
let start = std::time::Instant::now();
let (mut out, mut found) = (Vec::new(), 0);
for item in &items {
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
found += out.len();
}
let binned = start.elapsed();
assert_eq!(hits, found);
println!("scan {scan:?}, grid {binned:?}");
}
}
+27 -3
View File
@@ -246,17 +246,41 @@ mod tests {
needs_sync: false,
};
let mut still = item.clone();
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
assert!(still.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
assert!(carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[]
));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
carried.tick(
&world,
assets.registry,
&facts,
&collision,
false,
&fluids,
&[],
);
}
assert!(carried.position.x > at.x + 0.5);
}
+20 -4
View File
@@ -40,23 +40,39 @@ impl Culler {
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if start >= vertices.len() || self.remaining.get() == 0 {
if self.remaining.get() == 0 {
return false;
}
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
return false;
};
self.occluded(min, max, opaque)
}
/// Camera-relative bounds grown to include floating-point and rasterization
/// edges. The asynchronous path sends these small queries to its worker.
pub fn bounds(
camera: [f64; 3],
vertices: &[crate::mesher::Vertex],
start: usize,
) -> Option<([f64; 3], [f64; 3])> {
if start >= vertices.len() {
return None;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
for a in 0..3 {
let value = f64::from(vertex.pos[a]) + self.camera[a];
let value = f64::from(vertex.pos[a]) + camera[a];
if !value.is_finite() {
return false;
return None;
}
min[a] = min[a].min(value);
max[a] = max[a].max(value);
}
}
// Cover floating-point and rasterization edges as well as the vertices.
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
}
/// Returns true only when a complete opaque block plane covers the bounds.
+196 -63
View File
@@ -190,6 +190,7 @@ mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
mod native_claimed_items;
mod native_components;
mod native_criteria;
mod native_entities;
@@ -237,6 +238,8 @@ mod prediction;
mod pumpkin;
mod quick_actions;
mod raids;
mod ram_limit;
mod random_textures;
mod recipe_book;
mod remote;
mod remote_camera;
@@ -268,6 +271,7 @@ mod shield;
mod shoulders;
mod sign_edit;
mod sniffers;
mod social;
mod spawner;
mod spear;
mod spectator;
@@ -760,6 +764,8 @@ enum Screen {
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// A game master block's edit screen (game/block_editor.rs).
@@ -796,6 +802,7 @@ impl Screen {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
Screen::ServerLinks => "server_links",
@@ -2198,6 +2205,7 @@ struct Game<'a> {
// Physical input is separate from remapped and toggled movement keys.
physical_keys: HashSet<KeyCode>,
physical_buttons: HashSet<MouseButton>,
drag_button: Option<MouseButton>,
/// Control + left click as a right click (macOS).
right_click_emulation: horizon_client_input::mouse::RightClickEmulation,
left: bool,
@@ -2301,6 +2309,8 @@ struct Game<'a> {
session_lock: Option<horizon_client::save::SessionLock>,
audio: Audio,
music: horizon_client::music::MusicManager,
/// The tracks native mods play over the music (`native_music_layers`).
music_layers: horizon_client::music::MusicLayers,
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
/// The Music Toast option the toast was last set up for.
@@ -2618,6 +2628,8 @@ struct Game<'a> {
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
/// The screen of a mod block's menu open on this client (`native_screen`).
mod_screen: Option<native_hooks::OpenNativeScreen>,
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
@@ -2644,6 +2656,8 @@ struct Game<'a> {
panel: native_panel::Panel,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// The items native client mods claim and the events they haven't polled (game/native_claimed_items.rs).
native_input: native_claimed_items::ModInput,
/// The native lifecycle bookkeeping.
native_lifecycle: native_lifecycle::NativeLifecycle,
/// Native mods' messages waiting (`game/native_net.rs`).
@@ -2655,6 +2669,8 @@ struct Game<'a> {
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
entity_culler: Option<horizon_client::entity_culling::Culler>,
async_entity_culler: Option<entity_culling::AsyncCuller>,
async_entity_culling_failed: bool,
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
/// build the player's model in first person); (false, false) without a shader pack.
pack_shadow_casters: (bool, bool),
@@ -2685,6 +2701,8 @@ struct Game<'a> {
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
options_to_world_options: bool,
@@ -2713,6 +2731,8 @@ struct Game<'a> {
debug_options: Option<debug_options::DebugOptionsUi>,
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
/// How far memory holds the game below the player's settings (`game/ram_limit.rs`).
ram_limit: horizon_client::ram_limit::Governor,
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
/// CPU time, in percent.
gpu_utilization: f64,
@@ -3687,6 +3707,8 @@ impl Game<'_> {
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
// A LAN or server guest only shows the host's items.
let merging = self.remote.is_none();
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
for i in 0..items.len() {
if removed[i] {
continue;
@@ -3711,6 +3733,14 @@ impl Game<'_> {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
// where the item can reach this tick (a block of slack for the next velocity).
let reach = Vec3::new(
item.velocity.x.abs() + 1.0,
item.velocity.y.abs() + 1.0,
item.velocity.z.abs() + 1.0,
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
let alive = item.tick(
&self.world,
self.pack.assets.registry,
@@ -3718,6 +3748,7 @@ impl Game<'_> {
self.collision,
no_physics,
&self.res.motion().fluids,
&nearby,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
@@ -5576,10 +5607,11 @@ impl Game<'_> {
return Ok(Some(Hand::Main));
}
// An EntityHitResult may fall through to Item.use, never to useItemOn.
let block_hit = if matches!(guest, Some(GuestUse::Item(_))) || self.entity_targeted() {
None
} else {
self.pick()
let block_hit = match guest {
Some(GuestUse::OnBlock(_)) => self.pick(),
Some(GuestUse::Item(_)) => None,
_ if self.entity_targeted() => None,
_ => self.pick(),
};
let hands = match guest {
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
@@ -5858,7 +5890,9 @@ impl Game<'_> {
self.trigger_default_block_use(hit.pos);
}
}
self.swing_arm();
if result.should_swing() {
self.swing_arm();
}
return Ok(true);
}
}
@@ -5956,9 +5990,7 @@ impl Game<'_> {
return Ok(true);
}
if (!on_block && self.use_potion_item(&stack)?)
|| ((block_hit.is_some() || !spawn_egg)
&& (!on_block || spawn_egg)
&& self.throw_item(&stack)?)
|| ((!on_block || (spawn_egg && block_hit.is_some())) && self.throw_item(&stack)?)
{
return Ok(true);
}
@@ -7374,6 +7406,36 @@ impl Game<'_> {
}
}
}
/// Apply a physical key to every mapping bound to it.
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(k, pressed, repeat)?;
}
Err(button) => {
let action = match (pressed, repeat) {
(false, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
Ok(())
}
/// A key event after rebinding, as the default key of its mapping (the
/// keyboard and controllers both end here).
fn mapped_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
@@ -7479,6 +7541,7 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
@@ -8178,6 +8241,7 @@ impl Game<'_> {
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
@@ -10444,14 +10508,19 @@ impl Game<'_> {
self.res.block_entity_render().renders(*state)
&& (off_screen(*state) || in_view(p))
});
if self.entity_culler.is_some() || self.pack_ids.is_some() {
if self.entity_culler.is_some()
|| self.async_entity_culler.is_some()
|| self.pack_ids.is_some()
{
for block in visible {
let start = out.len();
self.mark_block_entity(start, block.1);
self.res
.block_entity_render()
.render(&scene, std::iter::once(block), &mut out);
if !off_screen(block.1) && self.entity_culler.is_some() {
if !off_screen(block.1)
&& (self.entity_culler.is_some() || self.async_entity_culler.is_some())
{
self.cull_entity_vertices(&mut out, start);
}
}
@@ -10648,6 +10717,7 @@ impl Game<'_> {
}
}
}
self.finish_async_entity_culling();
out
}
@@ -11464,6 +11534,7 @@ impl Game<'_> {
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
self.native_server_data_loaded(false);
self.sync_recipe_content();
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
@@ -13338,15 +13409,26 @@ impl Game<'_> {
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
let abilities = self.chat_abilities();
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
self.streaming.radius = self.settings.render_distance;
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
// LevelExtractor.allChanged when they change.
self.streaming.radius = self.render_distance_in_use();
// Options.biomeBlendRadius, ambientOcclusion, cutoutLeaves and the
// leaf-culling mode: LevelExtractor.allChanged when they change.
let leaf_culling = self.effective_leaf_culling();
let (s, st) = (&self.settings, &self.streaming);
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
let remesh = (
s.biome_blend,
s.smooth_lighting,
s.cutout_leaves,
leaf_culling,
) != (
st.biome_blend,
st.smooth_lighting,
st.cutout_leaves,
st.leaf_culling,
);
self.streaming.biome_blend = self.settings.biome_blend;
self.streaming.smooth_lighting = self.settings.smooth_lighting;
self.streaming.cutout_leaves = self.settings.cutout_leaves;
self.streaming.leaf_culling = leaf_culling;
if remesh {
self.all_sections_changed();
}
@@ -13799,6 +13881,8 @@ impl Game<'_> {
}
lines
}
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
/// container screen's hover target for their highlight and tooltip.
fn draw_creative(&mut self, batch: &mut HudBatch) {
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
screen_info::begin_container("creative");
@@ -13894,6 +13978,9 @@ impl Game<'_> {
}
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
let mut hovered: Option<(ItemStack, bool)> = None;
// Use the same padded slot bounds as clicks and the other menus, so
// highlights and tooltips remain visible between adjacent items.
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
if tab.inventory {
self.draw_player_preview(
batch,
@@ -13924,10 +14011,7 @@ impl Game<'_> {
continue;
};
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -13941,10 +14025,7 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let over = self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0;
let over = hovered_slot == Some(c);
if over {
self.draw_slot_highlight(batch, false, x, y);
}
@@ -15243,6 +15324,7 @@ impl Game<'_> {
}
}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
@@ -15285,6 +15367,11 @@ impl Game<'_> {
// an active use in the same tick.
self.release_use_for_keybind_screen();
}
// key.socialInteractions.
KeyCode::KeyP if self.screen == Screen::Playing => {
self.open_social(false);
self.release_use_for_keybind_screen();
}
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
// key.quickActions: only open if a datapack provides dialog actions.
KeyCode::KeyG if self.screen == Screen::Playing => {
@@ -15477,9 +15564,13 @@ impl Game<'_> {
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
let distance = match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
let distance = if self.ram_limit.far_off() {
0
} else {
match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
}
};
(distance, reduced)
}
@@ -15819,7 +15910,21 @@ impl ApplicationHandler for Game<'_> {
self.previous = Instant::now();
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
&& let WindowEvent::KeyboardInput {
event,
is_synthetic: false,
..
} = &event
{
Some((event.clone(), glfw_modifiers(self.modifiers)))
} else {
None
};
self.handle_window_event(event_loop, id, event);
if let Some((event, modifiers)) = key {
self.native_input_key(&event, modifiers);
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
@@ -15922,6 +16027,7 @@ impl Game<'_> {
if let WindowEvent::Focused(false) = &event {
self.physical_keys.clear();
self.physical_buttons.clear();
self.drag_button = None;
}
if let WindowEvent::ModifiersChanged(m) = &event {
self.modifiers = m.state();
@@ -16188,6 +16294,21 @@ impl Game<'_> {
return;
}
let result: Result<()> = (|| {
if let WindowEvent::MouseInput { state, button, .. } = &event
&& !self.pad_synthetic_input()
{
self.track_drag_button(*button, *state == ElementState::Pressed);
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Released
&& let PhysicalKey::Code(key) = event.physical_key
&& self.screen_key_released(key)
{
// Screen cancellation must not leave a held keyboard mapping down.
self.keys.remove(&key);
self.mapped_key_event(key, false, false)?;
return Ok(());
}
// ScreenEvent.MouseButtonPressed.Pre / MouseButtonReleased.Pre:
// the open screen hears of the press before anything reads it, and
// a cancelled one goes no further — no hotbar key, no slot, no
@@ -16280,7 +16401,11 @@ impl Game<'_> {
WindowEvent::CursorMoved { position, .. } => {
if let Some(r) = &self.renderer {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen_mouse_dragged(previous) {
return Ok(());
}
if self.dialog_open() {
self.dialog_drag();
}
@@ -16380,10 +16505,28 @@ impl Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
{
if !self.pad_synthetic_input() {
let mut canceled = false;
self.native_mouse_scrolling(
[scaled(raw_x), scaled(raw_y)],
[horizontal_steps, vertical_steps],
[
self.physical_buttons.contains(&MouseButton::Left),
self.physical_buttons.contains(&MouseButton::Middle),
self.physical_buttons.contains(&MouseButton::Right),
],
[self.cursor.0, self.cursor.1],
glfw_modifiers(self.modifiers),
&mut canceled,
);
if canceled {
return Ok(());
}
}
if self.spectator() && self.spectator_gui.is_menu_active() {
self.spectator_scroll(-wheel);
} else if self.spectator() {
@@ -16405,6 +16548,9 @@ impl Game<'_> {
if self.screen == Screen::Chat && d != 0.0 {
self.chat_scroll(d);
}
if self.screen == Screen::Social && d != 0.0 {
self.social_scroll(d);
}
if self.screen == Screen::CommandBlock && d != 0.0 {
self.command_block_scroll(d);
}
@@ -16654,6 +16800,9 @@ impl Game<'_> {
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
}
Screen::CommandBlock if pressed && button == MouseButton::Left => {
self.command_block_click()
}
@@ -16811,6 +16960,9 @@ impl Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
self.win_key(&event)?
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Social => {
self.social_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
self.command_block_key(&event)
}
@@ -16951,38 +17103,7 @@ impl Game<'_> {
{
self.smooth_camera = !self.smooth_camera;
}
// Key binds: act as the default key of every mapping bound
// to it (KeyMapping.set drives them all).
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(
k,
event.state == ElementState::Pressed,
event.repeat,
)?;
}
Err(button) => {
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => KeyAction::Release,
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
if let Some(pressed) = keyboard_button(
gameplay_has_keyboard(
self.screen,
self.title.is_some(),
self.dialog_open(),
),
action,
) {
self.play_mouse(button, pressed)?;
} else if container_key_clones(button, action) {
self.inventory_pick_key();
}
}
}
}
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
}
WindowEvent::RedrawRequested => {
self.observe_fullscreen();
@@ -17113,12 +17234,13 @@ impl Game<'_> {
self.frame_stats.record_gpu(&r.gpu_times);
r.gpu_times.clear();
}
horizon_client::ram_limit::frame();
self.limit_frame();
self.pause = self.pauses_game();
if let Some(server) = &self.integrated {
server.set_paused(self.pause);
server.set_distances(
self.settings.render_distance,
self.render_distance_in_use(),
self.settings.simulation_distance,
self.settings.entity_distance,
);
@@ -17421,6 +17543,7 @@ impl<'a> Game<'a> {
keys: HashSet::new(),
physical_keys: HashSet::new(),
physical_buttons: HashSet::new(),
drag_button: None,
right_click_emulation: Default::default(),
left: false,
right: false,
@@ -17486,6 +17609,7 @@ impl<'a> Game<'a> {
);
music
},
music_layers: Default::default(),
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
@@ -17674,6 +17798,7 @@ impl<'a> Game<'a> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mod_screen: None,
mod_container_viewers: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
@@ -17689,6 +17814,8 @@ impl<'a> Game<'a> {
agent: agent::Agent::default(),
view_frustum: None,
entity_culler: None,
async_entity_culler: None,
async_entity_culling_failed: false,
pack_shadow_casters: (false, false),
pending_uploads: Vec::new(),
frame_graph: Default::default(),
@@ -17697,6 +17824,7 @@ impl<'a> Game<'a> {
recipe_tables: Default::default(),
panel: Default::default(),
native_shaders: Default::default(),
native_input: Default::default(),
native_lifecycle: Default::default(),
native_net: Default::default(),
native_mod_screen: None,
@@ -17719,6 +17847,7 @@ impl<'a> Game<'a> {
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
debug_entries: horizon_client::debug_entries::DebugEntries::load(
@@ -17726,6 +17855,7 @@ impl<'a> Game<'a> {
),
debug_options: None,
memory_meter: Default::default(),
ram_limit: Default::default(),
gpu_utilization: 0.0,
rendered_mobs: Default::default(),
spawn_counts: HashMap::new(),
@@ -18083,7 +18213,7 @@ impl Game<'_> {
fn recipes(&self) -> &Recipes {
match &self.world_tables {
Some(t) => &t.recipes,
None => self.res.recipes(),
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
}
}
fn stations(&self) -> &horizon_client::stations::StationData {
@@ -18156,6 +18286,8 @@ impl Game<'_> {
}
self.world_tables = self.build_world_tables();
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
self.sync_recipe_content();
// PlayerList.reloadResources: the tags and recipes go to the players
// and their advancements reload. The synchronized registries (the
// dialogs, and the commands' ids of them) stay the hub's.
@@ -20125,6 +20257,7 @@ mod tests {
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
Screen::ServerLinks,
@@ -2006,7 +2006,7 @@ impl Game<'_> {
criteria.push(adv::Criterion {
name: name.clone(),
trigger: String::new(),
conditions: Value::Null,
conditions: Value::Null.into(),
});
}
}
+1
View File
@@ -417,6 +417,7 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
+26 -9
View File
@@ -727,20 +727,20 @@ impl Game<'_> {
true
}
pub(super) fn tick_armor_stands(&mut self) {
let mut scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let mut dead = Vec::new();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
for i in 0..self.armor_stands.len() {
let at = self.armor_stands[i].position;
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
self.armor_stands[i].previous = at;
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
continue;
}
self.armor_stand_location_effects(i, false);
let a = &mut self.armor_stands[i];
a.previous = a.position;
a.previous_yaw = a.yaw;
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
let (world, registry, facts, collision) = (
&self.world,
@@ -748,14 +748,31 @@ impl Game<'_> {
self.res.facts(),
self.collision,
);
let mut scene = horizon_client::collisions::SceneCollisions {
world,
binding: collision,
subject: None,
};
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push(
world, registry, facts, collision, bounds, velocity,
);
});
if in_water {
a.enchantment_fall_distance = 0.0;
} else {
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
}
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
if a.on_ground {
a.enchantment_fall_distance = 0.0;
}
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
dead.push(i);
}
if landed {
self.armor_stand_location_effects(i, true);
}
}
for i in dead.into_iter().rev() {
self.break_armor_stand_by_anything(i);
+23 -14
View File
@@ -57,8 +57,8 @@ pub(super) struct Boat {
above_bubble_column: bool,
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
bubble_down: bool,
/// A bubble column's drag asked a rider the server can't move itself (a
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
/// The server asked a rider whose client moves it (a player) to get out:
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
pub eject_riders: bool,
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
tick_count: i32,
@@ -101,6 +101,17 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
/// underwater ticks; any other status resets the counter.
fn tick_submersion(&mut self) {
self.out_of_control =
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
self.out_of_control + 1.0
} else {
0.0
};
}
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
id,
@@ -559,11 +570,7 @@ impl Game<'_> {
}
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
b.out_of_control + 1.0
} else {
0.0
};
b.tick_submersion();
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
if !controlled {
@@ -751,8 +758,13 @@ impl Game<'_> {
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
continue;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&self.boats[i].id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
@@ -776,6 +788,7 @@ impl Game<'_> {
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
self.boats[i].tick_submersion();
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
@@ -882,8 +895,8 @@ impl Game<'_> {
v.y = 0.6;
}
}
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
/// window's player gets out, and a server's player is asked to
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
/// gets out, and a server's player is asked to
/// (`guest_vehicle_tick` lets it out through its client).
fn eject_boat_riders(&mut self, i: usize) {
self.eject_boat_mobs(i, false);
@@ -918,10 +931,6 @@ impl Game<'_> {
/// window is its own client.
fn boat_bubble_tick(&mut self, i: usize) {
let id = self.boats[i].id;
// The boat a server's player drives is not ticked here: its status is.
if self.guest_vehicles().contains(&id) {
self.refresh_boat_status(i);
}
// An ejection nobody took (its player left the boat first) is void.
if !self.guest_rides(id) {
self.boats[i].eject_riders = false;
+19 -3
View File
@@ -63,6 +63,8 @@ impl Game<'_> {
) -> horizon_client::command::Dispatcher {
let mut names = Vec::new();
self.native_commands(&mut names);
// The window answers this one itself, as it does a client mod's.
names.push(horizon_client::ram_limit::COMMAND);
let base = match base {
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
@@ -1092,9 +1094,12 @@ impl Game<'_> {
if self.ui.hide_gui && !focused {
return;
}
self.ui
.chat
.draw(batch, &self.gui.atlas, self.ticks, focused);
self.ui.chat.smooth = self.settings.extras.chat_animation;
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
// The fractional tick makes the smooth chat extra's slide move every
// frame; vanilla's fade reads it too.
let now = self.ticks as f32 + self.clock.fraction() as f32;
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
/// event has one, the entity type and the UUID.
@@ -1555,6 +1560,17 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_ram_limit_command_is_known_to_the_chat_box() {
let reg = Registries::default();
let vanilla = horizon_client::command::vanilla::game_dispatcher();
assert!(vanilla.find(horizon_client::ram_limit::COMMAND).is_none());
let tree = with_native_commands(vanilla, &[horizon_client::ram_limit::COMMAND]);
for typed in ["ramlimit", "ramlimit 2048", "ramlimit off"] {
assert!(tree.parse(typed, &reg).executable().is_ok(), "{typed}");
}
}
/// A native mod's command is in the tree the chat box uses (#456): known
/// rather than red, and offered as it types. With no mod claiming one the
/// tree is the game's own, as it was before the mods were asked.
@@ -183,6 +183,10 @@ impl Game<'_> {
self.mod_browser_text(None, true);
return Ok(());
}
Screen::Social => {
self.social_backspace();
return Ok(());
}
_ => {}
}
}
@@ -205,6 +209,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::Social => self.social_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
@@ -1052,6 +1057,7 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
+118 -13
View File
@@ -771,6 +771,7 @@ impl Game<'_> {
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.items().block_tags,
landing: &self.motion.landing,
biomes: world_biomes!(self),
mob_griefing: self.ui.rules.bool("mob_griefing"),
universal_anger: self.ui.rules.bool("universal_anger"),
@@ -1774,6 +1775,40 @@ impl Game<'_> {
self.mob_hits_player(amount, from, kind);
}
MobEvent::ElderCurse { at } => self.elder_curse(at),
MobEvent::SetVelocity { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = v;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = v;
}
}
}
}
MobEvent::RemoveKilled { target } => {
// The bite that killed it ran `LivingEntity.die` (loot, such
// as a magma cube's froglight, and experience) before
// `eatEntity` removes it: a removed mob never reaches the
// next tick's deaths, so it dies here first.
if let Some(m) = self
.mobs
.iter()
.find(|m| m.id == target && !m.alive() && !m.removed)
.cloned()
{
if self.died.insert(target) {
self.mob_died(&m);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) {
m.removed = true;
}
}
}
MobEvent::Push { target, velocity } => {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
match target {
@@ -5730,13 +5765,29 @@ impl Game<'_> {
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let mob_type = parent.kind.clone();
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
let id = if species == Species::Piglin {
// SpawnEggItem.spawnOffspringFromSpawnEgg: a non-AgeableMob is
// constructed, then setBaby(true), without finalizeSpawn. Giving
// it adult spawn gear first leaves the baby holding a weapon.
self.next_entity += 1;
let id = self.next_entity;
self.mobs
.push(Mob::new(id, mob_type, Vec3::new(at[0], at[1], at[2]), 0.0));
id
} else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
id
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
if species == Species::Piglin {
self.native_mobs_joined(self.mobs.len() - 1, false);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
@@ -5750,17 +5801,49 @@ impl Game<'_> {
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let Some(hit) = self.pick() else {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
// EntityType.canSpawn: no monsters in peaceful (FAIL ends the click).
let id = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
if self.ui.difficulty == 0
&& let Some(t) = self.res.mob_types().get(&id)
&& !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
@@ -5776,11 +5859,15 @@ impl Game<'_> {
}
return Ok(true);
}
let p = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
let p = if fluid {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
@@ -5795,6 +5882,8 @@ impl Game<'_> {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.stats.add("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -5909,6 +5998,21 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
..Default::default()
});
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
@@ -6793,6 +6897,7 @@ mod pearl_tests {
thundering: false,
nether: false,
block_tags: &items.block_tags,
landing: &motion.landing,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
+106 -10
View File
@@ -990,6 +990,8 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
// The panic that ended the tick loop, when one did.
let mut crashed: Option<String> = None;
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
let mut braked = Instant::now();
@@ -998,10 +1000,18 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
// The console's lines and the control socket run in the tick too
// (`handleConsoleInputs`): a panic there is the same crash.
let prelude = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
self.poll_server_control(&mut state);
}));
if let Err(payload) = prelude {
crashed = Some(panic_message(payload.as_ref()));
break;
}
self.poll_server_control(&mut state);
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
if pause_after > 0 {
// A `/pregen` task keeps the server busy as a player would:
@@ -1029,11 +1039,23 @@ impl Game<'_> {
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
})?,
None => self.tick_server_timed(paused)?,
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
// report and still stops the server (saves). A panic here does the same.
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
}),
None => self.tick_server_timed(paused),
}
}));
match ticked {
Ok(result) => result?,
Err(payload) => {
crashed = Some(panic_message(payload.as_ref()));
break;
}
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
@@ -1067,7 +1089,10 @@ impl Game<'_> {
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.stop_dedicated()
match crashed {
Some(message) => self.stop_after_crash(&message),
None => self.stop_dedicated(),
}
}
/// A dedicated server of its own, not single player's server thread
@@ -1237,6 +1262,7 @@ impl Game<'_> {
self.console_command(state, line);
self.console_broadcast = None;
if let Some(mut host) = self.lan.take() {
crash_fixture("hub", || host.hub.guests.iter().any(|g| g.placed));
self.run_level_teleports(&mut host);
self.send_admin_feedback(&mut host, None);
self.lan = Some(host);
@@ -1818,6 +1844,41 @@ impl Game<'_> {
}
}
/// `MinecraftServer.runServer`'s `finally`: a tick that panicked still saves. What the panic
/// interrupted is not trusted: a guest's turn (`as_guest`) holds the game's player state
/// swapped in, so its record is older than what the game holds, and writing it would put
/// back older data than the last autosave; that guest keeps the file it has. A hub that the
/// panic took with it (`self.lan.take()` was in progress) saves no players at all. The
/// levels, the scoreboard and the other state are saved as in a normal stop.
fn stop_after_crash(&mut self, message: &str) -> Result<()> {
log("ERROR", &format!("The server thread panicked: {message}"));
let acting = self.lan_acting;
let hub_lost = self.lan.is_none();
if let (Some(conn), Some(host)) = (acting, self.lan.as_mut())
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
guest.placed = false;
}
if hub_lost {
log(
"WARN",
"The panic interrupted the network tick: players are not saved, only the world",
);
} else if acting.is_some() {
log(
"WARN",
"The panic interrupted a player's turn: that player keeps the last saved file",
);
}
self.native_lifecycle.begin_close();
log("INFO", "Saving worlds");
self.save_game()?;
self.log_saved_levels();
self.native_lifecycle_world_closed();
self.lan_close(false);
Err(format!("the server thread panicked: {message}").into())
}
fn stop_dedicated(&mut self) -> Result<()> {
// The close request starts before player and world saves.
self.native_lifecycle.begin_close();
@@ -1855,6 +1916,28 @@ impl Game<'_> {
}
}
/// `tools/test_dedicated_crash_save.py`'s fixture: with `HORIZON_CRASH_FIXTURE` naming `point`
/// (`guest`: during a placed guest's turn, `hub`: while a console line has the hub out and a
/// player is in), the tick panics there, to see what `stop_after_crash` keeps.
/// `armed` is asked only when the variable names `point`, so the tick pays one
/// load of a `OnceLock` without it.
pub(super) fn crash_fixture(point: &str, armed: impl FnOnce() -> bool) {
static FIXTURE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let fixture = FIXTURE.get_or_init(|| std::env::var("HORIZON_CRASH_FIXTURE").ok());
if fixture.as_deref() == Some(point) && armed() {
panic!("crash fixture at {point}");
}
}
/// The text of a panic payload (`panic!("..")` is a `&str`, a formatted one a `String`).
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_owned())
}
/// The width and height of a PNG (its IHDR chunk).
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
@@ -1867,7 +1950,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use super::{capture_log, log, log_from, panic_message};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
@@ -1875,6 +1958,19 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
/// named as unknown, so the crash line is never empty.
#[test]
fn a_panic_payload_is_read_as_text() {
let literal = std::panic::catch_unwind(|| panic!("a literal")).unwrap_err();
assert_eq!(panic_message(literal.as_ref()), "a literal");
let n = 3;
let formatted = std::panic::catch_unwind(|| panic!("tick {n}")).unwrap_err();
assert_eq!(panic_message(formatted.as_ref()), "tick 3");
let other = std::panic::catch_unwind(|| std::panic::panic_any(7_u8)).unwrap_err();
assert_eq!(panic_message(other.as_ref()), "unknown panic");
}
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
+101 -30
View File
@@ -790,40 +790,73 @@ impl Game<'_> {
return;
}
let riding = self.riding.is_some();
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
let disks = location_disks(
&self.stations().enchantments,
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
on_ground,
riding,
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at);
self.replace_disk(&effect, level, at, Who::Player);
}
}
/// `LivingEntity.onChangedBlock`, landing and `collectEquipmentChanges`
/// for an armor stand: run location effects before travel (including a
/// piston push), on landing, and for newly equipped items.
pub(super) fn armor_stand_location_effects(&mut self, index: usize, landed: bool) {
let stand = &mut self.armor_stands[index];
let block = BlockPos::containing(stand.position);
let moved = !landed && stand.enchantment_block.replace(block) != Some(block);
let changed: [bool; 6] =
std::array::from_fn(|i| stand.equipment[i] != stand.enchantment_equipment[i]);
if !moved && !landed && !changed.iter().any(|c| *c) {
return;
}
stand.enchantment_equipment.clone_from(&stand.equipment);
let stand = &self.armor_stands[index];
let at = [stand.position.x, stand.position.y, stand.position.z];
let who = Who::Mob(stand.id);
let equipment = stand
.equipment
.iter()
.zip(horizon_client::entities::ArmorStand::SLOTS)
.enumerate()
.filter(|(i, _)| moved || landed || changed[*i])
.filter(|(_, (stack, _))| {
moved
|| landed
|| !stack.as_ref().is_some_and(|stack| {
let data = stack.data();
let component = |id| data.and_then(|data| data.other(id));
let max_damage = match component("minecraft:max_damage") {
Some(horizon_world::nbt::Tag::Int(value)) => *value as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage),
};
max_damage > 0
&& component("minecraft:unbreakable").is_none()
&& component("!minecraft:max_damage").is_none()
&& component("!minecraft:damage").is_none()
&& stack.damage >= max_damage
})
})
.map(|(_, item)| item);
let disks = location_disks(
&self.stations().enchantments,
equipment,
stand.on_ground,
stand.vehicle.is_some(),
);
for (effect, level) in disks {
self.replace_disk(&effect, level, at, who);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3], who: Who) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
@@ -861,7 +894,7 @@ impl Game<'_> {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(Who::Player),
Some(who),
Some(new),
);
}
@@ -966,6 +999,44 @@ impl Game<'_> {
}
}
/// `EnchantmentHelper.runLocationChangedEffects`: the replace-disk effects of
/// active equipment slots, with the wearer's ground/passenger requirements.
fn location_disks<'a>(
enchantments: &Enchantments,
equipment: impl IntoIterator<Item = (&'a Option<ItemStack>, &'static str)>,
on_ground: bool,
riding: bool,
) -> Vec<(Value, u32)> {
let mut disks = Vec::new();
for (stack, slot) in equipment {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = enchantments.get(id) else {
continue;
};
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
disks
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
+558 -18
View File
@@ -1,9 +1,335 @@
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
use super::{Game, Vertex};
use horizon_client::{
blocks::{RenderShape, StateFacts},
blocks::{BlockFacts, RenderShape, StateFacts},
chunk::Chunk,
entity_culling::Culler,
world::World,
};
use std::{
cell::RefCell,
sync::{
Arc, Weak,
mpsc::{self, Receiver, SyncSender, TrySendError},
},
thread::JoinHandle,
};
type ChunkKey = (i32, i32);
type SectionKey = (i32, i32, i32);
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct CullKey {
camera: [u64; 3],
min: [u64; 3],
max: [u64; 3],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SnapshotKey {
world_revision: u64,
chunks_revision: u64,
bottom_y: i32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ChunkBounds {
min_x: i32,
max_x: i32,
min_z: i32,
max_z: i32,
}
/// Sorted weak chunk references avoid holding gameplay chunks or forcing
/// copy-on-write. A worker keeps only its current chunk strongly referenced.
#[derive(Clone)]
struct OcclusionSnapshot {
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
opaque_states: Arc<[bool]>,
}
impl OcclusionSnapshot {
fn chunk(&self, key: ChunkKey) -> Option<&Weak<Chunk>> {
self.chunks
.binary_search_by_key(&key, |(key, _)| *key)
.ok()
.map(|index| &self.chunks[index].1)
}
fn refresh_chunks(&mut self, world: &World, bounds: ChunkBounds) {
self.chunks.clear();
for x in bounds.min_x..=bounds.max_x {
for z in bounds.min_z..=bounds.max_z {
if let Some(chunk) = world.chunks.get(&(x, z)) {
self.chunks.push(((x, z), Arc::downgrade(chunk)));
}
}
}
}
fn chunks_match(&self, world: &World) -> bool {
self.chunks.iter().all(|(key, expected)| {
world
.chunks
.get(key)
.is_some_and(|current| std::ptr::eq(expected.as_ptr(), Arc::as_ptr(current)))
})
}
fn opaque(&self, pos: [i32; 3], last_chunk: &mut LastChunk) -> bool {
let key = (pos[0] >> 4, pos[2] >> 4);
let Some(chunk) = last_chunk.get(self, key) else {
return false;
};
let state = chunk.block(pos[0] & 15, pos[1], pos[2] & 15);
self.opaque_states
.get(state.0 as usize)
.copied()
.unwrap_or(false)
}
}
#[derive(Default)]
struct LastChunk {
key: Option<ChunkKey>,
value: Option<Arc<Chunk>>,
}
impl LastChunk {
fn get(&mut self, snapshot: &OcclusionSnapshot, key: ChunkKey) -> Option<&Arc<Chunk>> {
if self.key != Some(key) {
self.key = Some(key);
self.value = snapshot.chunk(key).and_then(Weak::upgrade);
}
self.value.as_ref()
}
}
struct OcclusionProof {
sections: Vec<SectionKey>,
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
}
struct CullResults {
stamp: SnapshotKey,
hidden: Vec<(CullKey, OcclusionProof)>,
scratch_queries: Vec<CullKey>,
}
struct CullJob {
stamp: SnapshotKey,
snapshot: Arc<OcclusionSnapshot>,
queries: Vec<CullKey>,
}
/// Non-blocking entity occlusion. Results are reused only for byte-identical
/// camera and mesh bounds, with the block chunks and rendered sections that
/// proved occlusion revalidated before hiding anything.
pub(super) struct AsyncCuller {
jobs: SyncSender<CullJob>,
results_rx: Receiver<CullResults>,
_thread: JoinHandle<()>,
snapshot: Option<(SnapshotKey, ChunkBounds, Arc<OcclusionSnapshot>)>,
opaque_states: Option<Arc<[bool]>>,
results: Option<CullResults>,
queries: RefCell<Vec<CullKey>>,
camera: [f64; 3],
stamp: SnapshotKey,
pending: bool,
}
impl AsyncCuller {
pub(super) fn new() -> std::io::Result<Self> {
let (job_tx, job_rx) = mpsc::sync_channel::<CullJob>(1);
let (result_tx, result_rx) = mpsc::channel::<CullResults>();
let thread = std::thread::Builder::new()
.name("entity-culling".into())
.spawn(move || {
while let Ok(job) = job_rx.recv() {
if result_tx.send(cull_job(job)).is_err() {
break;
}
}
})?;
Ok(Self {
jobs: job_tx,
results_rx: result_rx,
_thread: thread,
snapshot: None,
opaque_states: None,
results: None,
queries: RefCell::new(Vec::new()),
camera: [0.0; 3],
stamp: SnapshotKey {
world_revision: 0,
chunks_revision: 0,
bottom_y: 0,
},
pending: false,
})
}
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
while let Ok(mut results) = self.results_rx.try_recv() {
self.pending = false;
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
}
self.results = Some(results);
}
self.camera = camera.to_array();
self.stamp = SnapshotKey {
world_revision: world.revision,
chunks_revision: world.chunks.membership_revision(),
bottom_y: world.bottom_y(),
};
if self
.results
.as_ref()
.is_some_and(|result| result.stamp != self.stamp)
{
self.results = None;
}
self.queries.get_mut().clear();
}
fn query(&self, vertices: &[Vertex], start: usize) -> Option<CullKey> {
let (min, max) = Culler::bounds(self.camera, vertices, start)?;
Some(CullKey {
camera: self.camera.map(f64::to_bits),
min: min.map(f64::to_bits),
max: max.map(f64::to_bits),
})
}
fn proof(&self, key: &CullKey) -> Option<&OcclusionProof> {
let hidden = &self.results.as_ref()?.hidden;
let index = hidden.binary_search_by_key(key, |(key, _)| *key).ok()?;
Some(&hidden[index].1)
}
fn queue(&self, key: CullKey) {
self.queries.borrow_mut().push(key);
}
fn finish_frame(&mut self, world: &World, facts: &BlockFacts) {
let queries = self.queries.get_mut();
if self.pending {
queries.clear();
return;
}
if queries.is_empty() {
return;
}
queries.sort_unstable();
queries.dedup();
let Some(bounds) = snapshot_bounds(self.camera, queries) else {
queries.clear();
return;
};
let opaque_states = Arc::clone(
self.opaque_states
.get_or_insert_with(|| facts.states.iter().map(opaque).collect::<Vec<_>>().into()),
);
let snapshot = match &mut self.snapshot {
Some((stamp, cached_bounds, snapshot)) => {
if stamp != &self.stamp || *cached_bounds != bounds || !snapshot.chunks_match(world)
{
let snapshot = Arc::make_mut(snapshot);
snapshot.opaque_states = Arc::clone(&opaque_states);
snapshot.refresh_chunks(world, bounds);
*stamp = self.stamp;
*cached_bounds = bounds;
}
Arc::clone(snapshot)
}
None => {
let mut snapshot = OcclusionSnapshot {
chunks: Vec::new(),
opaque_states,
};
snapshot.refresh_chunks(world, bounds);
let snapshot = Arc::new(snapshot);
self.snapshot = Some((self.stamp, bounds, Arc::clone(&snapshot)));
snapshot
}
};
let queries = std::mem::take(queries);
let job = CullJob {
stamp: self.stamp,
snapshot,
queries,
};
match self.jobs.try_send(job) {
Ok(()) => self.pending = true,
Err(TrySendError::Full(job) | TrySendError::Disconnected(job)) => {
*self.queries.get_mut() = job.queries;
}
}
}
}
fn cull_job(mut job: CullJob) -> CullResults {
let camera = job.queries[0].camera.map(f64::from_bits);
let culler = Culler::new(camera, 8_192);
let mut last_chunk = LastChunk::default();
let mut hidden = Vec::with_capacity(job.queries.len());
let mut covered = Vec::new();
for &key in &job.queries {
covered.clear();
let min = key.min.map(f64::from_bits);
let max = key.max.map(f64::from_bits);
if culler.occluded(min, max, |pos| {
let opaque = job.snapshot.opaque(pos, &mut last_chunk);
if opaque {
covered.push(pos);
}
opaque
}) {
let mut sections = Vec::with_capacity(covered.len());
let mut proof_chunks: Vec<(ChunkKey, Weak<Chunk>)> = Vec::new();
let mut valid = true;
for &pos in &covered {
let chunk_key = (pos[0] >> 4, pos[2] >> 4);
let Some(y) = pos[1].checked_sub(job.stamp.bottom_y) else {
valid = false;
break;
};
sections.push((chunk_key.0, y.div_euclid(16), chunk_key.1));
if let Err(index) = proof_chunks.binary_search_by_key(&chunk_key, |(key, _)| *key) {
let Some(chunk) = job.snapshot.chunk(chunk_key) else {
valid = false;
break;
};
if chunk.strong_count() == 0 {
valid = false;
break;
}
proof_chunks.insert(index, (chunk_key, chunk.clone()));
}
}
if valid {
sections.sort_unstable();
sections.dedup();
hidden.push((
key,
OcclusionProof {
sections,
chunks: proof_chunks,
},
));
}
}
}
job.queries.clear();
CullResults {
stamp: job.stamp,
hidden,
scratch_queries: job.queries,
}
}
fn opaque(facts: &StateFacts) -> bool {
facts.render_shape == RenderShape::Model
@@ -12,6 +338,44 @@ fn opaque(facts: &StateFacts) -> bool {
&& facts.full_collision
}
fn snapshot_bounds(camera: [f64; 3], queries: &[CullKey]) -> Option<ChunkBounds> {
let mut min_x = camera[0];
let mut max_x = camera[0];
let mut min_z = camera[2];
let mut max_z = camera[2];
for query in queries {
let min = query.min.map(f64::from_bits);
let max = query.max.map(f64::from_bits);
min_x = min_x.min(min[0]);
max_x = max_x.max(max[0]);
min_z = min_z.min(min[2]);
max_z = max_z.max(max[2]);
}
// Culler::occluded expands projected cells by EPSILON. One block of slack
// also covers the camera's first cell when it lies exactly on a boundary.
let min_x = snapshot_cell(min_x - 1.0)? >> 4;
let max_x = snapshot_cell(max_x + 1.0)? >> 4;
let min_z = snapshot_cell(min_z - 1.0)? >> 4;
let max_z = snapshot_cell(max_z + 1.0)? >> 4;
let width = i64::from(max_x) - i64::from(min_x) + 1;
let depth = i64::from(max_z) - i64::from(min_z) + 1;
if width <= 0 || depth <= 0 || width.checked_mul(depth)? > MAX_SNAPSHOT_CHUNKS {
return None;
}
Some(ChunkBounds {
min_x,
max_x,
min_z,
max_z,
})
}
fn snapshot_cell(value: f64) -> Option<i32> {
let cell = value.floor();
(cell.is_finite() && cell >= f64::from(i32::MIN) && cell <= f64::from(i32::MAX))
.then_some(cell as i32)
}
impl Game<'_> {
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
@@ -23,41 +387,124 @@ impl Game<'_> {
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
self.entity_culler = (self.settings.horizon.entity_culling
let eligible = self.settings.horizon.entity_culling
!= horizon_client::graphics::EntityCullingMode::Off
// Packs can replace an opaque block's model or texture.
&& self.settings.resource_packs.iter().all(|id| id == "vanilla")
&& self.model_packs_are_vanilla()
&& self.player.is_some()
&& !self.spectator()
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2]))))
.then(|| Culler::new(camera.to_array(), 8_192));
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2])));
if !eligible {
self.entity_culler = None;
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
return;
}
match self.settings.horizon.entity_culling {
horizon_client::graphics::EntityCullingMode::Off => {
self.entity_culler = None;
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
}
horizon_client::graphics::EntityCullingMode::Synchronous => {
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
self.async_entity_culler = None;
self.async_entity_culling_failed = false;
}
horizon_client::graphics::EntityCullingMode::Asynchronous => {
self.entity_culler = None;
if self.async_entity_culler.is_none() && !self.async_entity_culling_failed {
match AsyncCuller::new() {
Ok(culler) => self.async_entity_culler = Some(culler),
Err(error) => {
log::warn!("Horizon: cannot start entity-culling worker: {error}");
self.async_entity_culling_failed = true;
}
}
}
if let Some(culler) = &mut self.async_entity_culler {
culler.begin_frame(camera, &self.world);
} else {
// If the worker cannot start, use the existing same-frame path.
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
}
}
}
}
/// Use complete generated geometry so equipment, animation and custom
/// models cannot stick out of an assumed collision-sized culling box.
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
let Some(culler) = &self.entity_culler else {
if let Some(culler) = &self.entity_culler {
let hidden = culler.hidden(vertices, start, |pos| self.occluder_ready(pos));
// A shader pack's shadow pass still draws what the camera cannot see.
if hidden
&& !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len()))
{
vertices.truncate(start);
}
return;
}
let Some(culler) = &self.async_entity_culler else {
return;
};
let hidden = culler.hidden(vertices, start, |pos| {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
let Some(query) = culler.query(vertices, start) else {
return;
};
let hidden = self
.async_entity_culler
.as_ref()
.and_then(|culler| culler.proof(&query))
.is_some_and(|proof| self.async_proof_is_current(proof));
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
vertices.truncate(start);
}
if let Some(culler) = &self.async_entity_culler {
culler.queue(query);
}
}
fn async_proof_is_current(&self, proof: &OcclusionProof) -> bool {
for (key, expected) in &proof.chunks {
let Some(current) = self.world.chunks.get(key) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
// Newly edited or not-yet-uploaded terrain is not an occluder.
if !std::ptr::eq(Arc::as_ptr(current), expected.as_ptr()) {
return false;
}
}
proof.sections.iter().all(|&section| {
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
});
// A shader pack's shadow pass still draws what the camera cannot see.
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
vertices.truncate(start);
})
}
fn occluder_ready(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
}
pub(super) fn finish_async_entity_culling(&mut self) {
if let Some(culler) = &mut self.async_entity_culler {
culler.finish_frame(&self.world, self.res.facts());
}
}
}
@@ -117,6 +564,99 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod async_tests {
use super::*;
use horizon_client::chunk::ColumnLayout;
fn wall(hole: Option<[i32; 3]>) -> (Arc<OcclusionSnapshot>, Vec<Arc<Chunk>>) {
let mut live = Vec::new();
let mut chunks = Vec::new();
for chunk_z in [-1, 0] {
let mut chunk = Chunk::with_layout(0, chunk_z, ColumnLayout::VANILLA);
for y in -1..=0 {
for z in -1..=0 {
let pos = [4, y, z];
if hole != Some(pos) {
chunk.set(pos[0] & 15, pos[1], pos[2] & 15, 1);
}
}
}
let chunk = Arc::new(chunk);
chunks.push(((0, chunk_z), Arc::downgrade(&chunk)));
live.push(chunk);
}
chunks.sort_unstable_by_key(|(key, _)| *key);
(
Arc::new(OcclusionSnapshot {
chunks,
opaque_states: Arc::from([false, true]),
}),
live,
)
}
fn job(snapshot: Arc<OcclusionSnapshot>) -> CullJob {
let camera = [0.0; 3];
let min = [7.95, -0.55, -0.55];
let max = [10.05, 0.55, 0.55];
CullJob {
stamp: SnapshotKey {
world_revision: 1,
chunks_revision: 1,
bottom_y: ColumnLayout::VANILLA.min_y(),
},
snapshot,
queries: vec![CullKey {
camera: camera.map(f64::to_bits),
min: min.map(f64::to_bits),
max: max.map(f64::to_bits),
}],
}
}
#[test]
fn async_snapshot_is_bounded_and_handles_negative_chunk_edges() {
let camera = [-16.0, 0.0, -16.0];
let key = CullKey {
camera: camera.map(f64::to_bits),
min: [-8.0, -1.0, -8.0].map(f64::to_bits),
max: [8.0, 1.0, 8.0].map(f64::to_bits),
};
assert_eq!(
snapshot_bounds(camera, &[key]),
Some(ChunkBounds {
min_x: -2,
max_x: 0,
min_z: -2,
max_z: 0,
})
);
let huge = CullKey {
camera: [0.0; 3].map(f64::to_bits),
min: [5_000.0, 0.0, 5_000.0].map(f64::to_bits),
max: [5_001.0, 1.0, 5_001.0].map(f64::to_bits),
};
assert_eq!(snapshot_bounds([0.0; 3], &[huge]), None);
}
#[test]
fn async_worker_only_hides_bounds_behind_a_complete_opaque_plane() {
let (snapshot, live) = wall(None);
let results = cull_job(job(snapshot));
assert_eq!(results.hidden.len(), 1);
assert_eq!(results.hidden[0].1.sections.len(), 4);
assert_eq!(results.hidden[0].1.chunks.len(), 2);
drop(live);
let (snapshot, live) = wall(Some([4, 0, 0]));
let results = cull_job(job(snapshot));
assert!(results.hidden.is_empty());
drop(live);
}
}
/// An entity's position as a `DVec3`.
pub(super) fn at(p: horizon_core::geometry::Vec3) -> glam::DVec3 {
glam::DVec3::new(p.x, p.y, p.z)
@@ -698,6 +698,74 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
// does nothing while it is in the ground; `hurtServer` never hurts them.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye_height = p.kind.eye_height();
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
let power = (1.0 - dist) * f64::from(exposure);
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
p.needs_sync = true;
}
self.creatures.projectiles = projectiles;
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
let feet = r.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.125, feet.y, feet.z - 0.125],
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
);
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
r.velocity = Vec3::new(
r.velocity.x + dir[0] * power,
r.velocity.y + dir[1] * power,
r.velocity.z + dir[2] * power,
);
}
self.rockets = rockets;
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
// (`AbstractArrow.push`) not pushed while in the ground.
let mut tridents = std::mem::take(&mut self.tridents);
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
let feet = t.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.25, feet.y, feet.z - 0.25],
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
);
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
t.velocity = Vec3::new(
t.velocity.x + dir[0] * power,
t.velocity.y + dir[1] * power,
t.velocity.z + dir[2] * power,
);
}
self.tridents = tridents;
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
+1
View File
@@ -62,6 +62,7 @@ impl Game<'_> {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
self.native_frame(elapsed);
self.recipes_received_now();
}
/// The frame counter for mods: fps, min, 1% low.
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
+12
View File
@@ -580,6 +580,12 @@ impl Game<'_> {
.map(|i| i.id)
});
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[h.position.x, h.position.y, h.position.z],
);
h.state = State::Hooked(id);
h.velocity = Vec3::ZERO;
self.hook = Some(h);
@@ -605,6 +611,12 @@ impl Game<'_> {
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
Some(horizon_client::mobs::Who::Player),
[hit.location.x, hit.location.y, hit.location.z],
);
h.velocity = rebound(h.velocity, hit.location, h.position);
}
}
+19
View File
@@ -35,6 +35,25 @@ impl Game<'_> {
)
.collect()
}
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
/// back keeps it still, so an item dropped there stays on top.
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
self.mobs
.iter()
.filter(|m| {
(m.species() == horizon_client::mobs::Species::HappyGhast
&& m.alive()
&& !m.is_baby()
&& happy_ghast::on_still_timeout(m))
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
})
.map(|m| m.bounds())
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(self.carts.iter().map(|c| c.bounds()))
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
pub(super) fn interact_happy_ghast(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
+2 -1
View File
@@ -21,7 +21,7 @@ impl Game<'_> {
return true;
}
match self.screen {
Screen::Chat | Screen::CommandBlock => true,
Screen::Chat | Screen::CommandBlock | Screen::Social => true,
Screen::DebugOptions => self
.debug_options
.as_ref()
@@ -117,6 +117,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(&text),
Screen::CommandBlock => self.command_block_type(&text),
Screen::Social => self.social_type(&text),
Screen::DebugOptions => self.debug_options_type(&text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
@@ -32,8 +32,37 @@ use horizon_protocol::{
text::literal,
};
use horizon_world::entity_move::EntityMoveWorld;
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
@@ -297,6 +326,9 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
super::dedicated::crash_fixture("guest", || {
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
});
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
@@ -1363,6 +1395,27 @@ impl Game<'_> {
}
}
/// `ContainerOpenersCounter` for the mod containers of the level in place:
/// the players with each one open, recounted from their menus, so a
/// change is heard whichever way a player left (`native_mod_container_viewers`).
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
if !g.spectator()
&& let Some(m) = &g.menu
&& let Slots::Block(pos) = m.slots
&& self.mod_menu_at(pos).is_some()
{
*now.entry(pos).or_default() += 1;
}
}
let dim = self.dim.id();
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
@@ -1869,6 +1922,23 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
// SpawnEggItem.use calls ServerLevel.mayInteract at its own
// fluid ray's position, unlike useOn's packet-supplied block.
let held = if hand == Hand::Off {
g.offhand.as_ref()
} else {
g.inventory.get(g.selected).and_then(Option::as_ref)
};
if held.is_some_and(|s| s.item.ends_with("_spawn_egg")) {
let hit = self
.as_guest(host, conn, |g| {
g.with_hand(hand, |g| g.spawn_egg_fluid_hit())
})
.flatten();
if hit.is_some_and(|h| self.under_spawn_protection(host, gi, h.pos)) {
return;
}
}
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
@@ -3235,6 +3305,7 @@ impl Game<'_> {
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_mod_container_viewers(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -133,8 +133,9 @@ impl Game<'_> {
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
ejected = std::mem::take(&mut b.eject_riders);
// `Entity.ejectPassengers`: every player in the boat must see
// the request; the common boat tick clears it once nobody rides.
ejected = b.eject_riders;
}
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
// is where it is now, so a move the server made (a piston, a push)
+2
View File
@@ -549,6 +549,8 @@ impl Game<'_> {
.is_some_and(|p| !p.streaming.forced.is_empty())
};
guests.iter().any(|g| g.dimension == d.id())
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
// A game test runs in its own level, players or not.
+67 -12
View File
@@ -7,7 +7,11 @@
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -452,10 +456,26 @@ impl Game<'_> {
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
/// positions). Returns the mobs in that box before the hit, as
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
/// destroys them; an orb has 5 health).
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let strike = Aabb::new(
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
);
// Whether the box of an entity standing at `p` (half width `w`,
// height `h`) meets the strike box.
let meets = move |p: [f64; 3], w: f64, h: f64| {
strike.intersects(Aabb::new(
Vec3::new(p[0] - w, p[1], p[2] - w),
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
))
};
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
@@ -465,7 +485,14 @@ impl Game<'_> {
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.filter(|m| {
m.alive()
&& meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
)
})
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
@@ -477,7 +504,11 @@ impl Game<'_> {
let mut events = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
if !inside([m.position.x, m.position.y, m.position.z]) {
if !meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
) {
continue;
}
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
@@ -554,10 +585,34 @@ impl Game<'_> {
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
}
}
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
// fire source, so a TNT cart does not prime.
let mut i = self.boats.len();
while i > 0 {
i -= 1;
let bb = self.boats[i].bounds();
if strike.intersects(bb) {
self.hurt_boat(i, 5.0, false);
}
}
let mut i = self.carts.len();
while i > 0 {
i -= 1;
let bb = self.carts[i].bounds();
if strike.intersects(bb) {
self.hurt_cart(i, 5.0, false, false);
}
}
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
let orbs_before = self.orb_ids();
self.orbs
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
self.native_orbs_left_since(&orbs_before);
// Every player in the box, the server's players too (0.6 x 1.8).
let strike_players = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
&& meets([p.x, p.y, p.z], 0.3, 1.8)
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
@@ -570,18 +625,18 @@ impl Game<'_> {
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike(self);
self.each_guest_hit(strike);
strike_players(self);
self.each_guest_hit(strike_players);
b.hit_items.extend(
self.dropped
.iter()
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
.map(|e| e.id),
);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
let before = self.dropped_ids();
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|| e.stack.item.contains("netherite")
|| (e.age < 20 && b.spared.contains(&e.id))
});
+56 -1
View File
@@ -414,13 +414,68 @@ impl Game<'_> {
}
self.rail_pos(x, y, z)
}
/// `AbstractMinecart.canCollideWith`, for `Entity.collide`'s `getEntityCollisions`:
/// `AbstractBoat.canVehicleCollide` takes any other cart, boat, shulker or pushable mob and
/// player, except one riding this cart. A cart that runs into one has a horizontal
/// collision, which sets a TNT minecart off.
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
let mut boxes: Vec<Aabb> = self
.carts
.iter()
.filter(|o| o.id != c.id)
.map(Minecart::bounds)
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(
self.mobs
.iter()
.filter(|m| {
!m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
})
.map(|m| m.bounds()),
)
.collect();
if self.riding != Some(c.id)
&& let Some(p) = self.player.as_ref()
&& !self.spectator()
{
boxes.push(p.bounds());
}
if let Some(host) = self.lan.as_ref() {
boxes.extend(
host.hub
.guests
.iter()
.filter(|g| {
host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& g.vehicle != Some(c.id)
})
.map(|g| g.bounds()),
);
}
boxes.retain(|b| b.intersects(near));
boxes
}
/// Entity.move for a cart: collisions zero the blocked components.
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
let mut scene = SceneCollisions {
let scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let entities = self.cart_entity_collisions(c);
let mut scene = horizon_client::collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
};
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
c.position = Vec3::new(
c.position.x + moved.x,
+15
View File
@@ -185,6 +185,12 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
@@ -272,6 +278,15 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
|| self
.settings
.mods
.installed
.iter()
.any(|m| m.native.is_some()),
);
let native_dir = Self::game_dir().join(mod_list::NATIVE_DIR);
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
self.settings.mods.installed.extend(unbuilt);
+6 -1
View File
@@ -14,7 +14,12 @@ impl Game<'_> {
if self.options.split_guest {
return;
}
let (music, volume) = self.situational_music();
let (mut music, mut volume) = self.situational_music();
// A native mod's own choice of track and volume, then its layers.
self.native_music(&mut music, &mut volume);
let mut layers = Vec::new();
self.native_music_layers(&mut layers);
self.music_layers.tick(&layers, &mut self.audio);
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
+1 -1
View File
@@ -260,7 +260,7 @@ impl Game<'_> {
Screen::Chat if self.title.is_none() => true,
Screen::Options => self.ui.options.typing(),
Screen::WorldOptions => self.world_options_typing(),
Screen::CommandBlock => true,
Screen::CommandBlock | Screen::Social => true,
Screen::Inventory => self.station_typing() || self.mod_browser_typing(),
_ => self
.title
@@ -0,0 +1,346 @@
//! A client mod's view and its own items (docs/native-mods.md, "A client mod's view and items"): the
//! first block along the view, across the distant terrain past the loaded chunks, and items whose
//! right-click is the mod's alone, heard as use events the mod polls.
use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
/// ends when the button comes up, the hand's item changes or leaves the
/// claim, the player respawns, or the game joins a server.
UseStop(NativeHeldItem, u32),
}
/// The farthest a ray along the view reaches, in blocks.
pub(crate) const RAYCAST_MAX: f32 = 1024.0;
/// The ids a mod may claim.
const CLAIMS_MAX: usize = 64;
/// Events kept for a mod between two polls.
const EVENTS_MAX: usize = 256;
/// A claimed item's use under way.
struct ModUse {
mod_id: String,
item: NativeHeldItem,
/// The player's `using` while it is this use: its slot and ticks.
slot: usize,
ticks: u32,
}
/// The items client mods claim, their events waiting for a poll, and the use under way.
#[derive(Default)]
pub(super) struct ModInput {
claims: BTreeMap<String, Vec<String>>,
events: BTreeMap<String, Vec<NativeModEvent>>,
using: Option<ModUse>,
}
impl ModInput {
fn claim(&mut self, mod_id: &str, ids: &[String]) {
let ids = ids.iter().take(CLAIMS_MAX).cloned().collect();
self.claims.insert(mod_id.to_owned(), ids);
}
/// The mod that claims `item`, the first in id order.
pub(super) fn claimant(&self, item: &str) -> Option<&str> {
self.claims
.iter()
.find(|(_, ids)| ids.iter().any(|i| i == item))
.map(|(id, _)| id.as_str())
}
fn push(&mut self, mod_id: &str, event: NativeModEvent) {
let events = self.events.entry(mod_id.to_owned()).or_default();
if events.len() < EVENTS_MAX {
events.push(event);
}
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.events.remove(mod_id).unwrap_or_default()
}
/// Mod `mod_id`'s use of `item` in `slot` begins, ending any other first.
fn start(&mut self, mod_id: &str, item: NativeHeldItem, slot: usize) {
self.end();
self.push(mod_id, NativeModEvent::UseStart(item.clone(), 0));
self.using = Some(ModUse {
mod_id: mod_id.to_owned(),
item,
slot,
ticks: 0,
});
}
/// Whether the player's use (`Game.using`) is the mod use under way. A
/// use the game reset and another item's begun since are not.
fn owns(&self, using: Option<(usize, u32)>) -> bool {
self.using
.as_ref()
.is_some_and(|u| using == Some((u.slot, u.ticks)))
}
fn tick(&mut self) {
if let Some(u) = &mut self.using {
u.ticks += 1;
}
}
/// The use under way ends: its mod hears `UseStop`.
fn end(&mut self) {
if let Some(u) = self.using.take() {
self.push(&u.mod_id, NativeModEvent::UseStop(u.item, u.ticks));
}
}
}
#[allow(dead_code)]
impl Game<'_> {
/// The stack in inventory slot `slot` as a mod's events carry it.
fn mod_held(&self, slot: usize) -> Option<NativeHeldItem> {
let stack = self.stack_in(slot)?;
let extra = stack.extra.as_deref();
Some(NativeHeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: slot as u32,
custom_name: extra
.and_then(|e| e.custom_name.as_ref())
.map(horizon_client::snbt::to_snbt),
custom_data: extra
.and_then(|e| e.other("minecraft:custom_data"))
.map(horizon_client::snbt::to_snbt),
})
}
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API @API:native_claimed_items@.
pub(super) fn native_raycast(&self, distance: f32) -> Option<NativeHit> {
let (d, hit) = self.native_view_hit()?;
(d <= distance.clamp(0.0, RAYCAST_MAX)).then_some(hit)
}
/// The first block along the view within `RAYCAST_MAX`, and its distance. API @API:native_claimed_items@.
pub(super) fn native_view_hit(&self) -> Option<(f32, NativeHit)> {
self.player.as_ref()?;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
hit.pos,
hit.point,
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
),
None => self.lod_ray(eye, dir, reach)?,
};
let distance = (0..3)
.map(|i| (point[i] - eye[i]).powi(2))
.sum::<f64>()
.sqrt();
Some((
distance as f32,
NativeHit {
block_x: pos[0],
block_y: pos[1],
block_z: pos[2],
x: point[0],
y: point[1],
z: point[2],
block: self.res.facts().get(state).name.to_string(),
},
))
}
/// A ray over the distant terrain (`Lod::ground`) past the loaded chunks, a block at a time.
fn lod_ray(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
let lod = self.lod.as_ref()?;
let mut t = 0.0;
while t <= reach {
let p = [
eye[0] + dir[0] * t,
eye[1] + dir[1] * t,
eye[2] + dir[2] * t,
];
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
if !self.world.loaded(x, z)
&& let Some((height, state)) = lod.ground(x, z)
&& p[1] <= f64::from(height)
{
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
}
t += 1.0;
}
None
}
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API @API:native_claimed_items@.
pub(super) fn native_claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_input.claim(mod_id, ids);
}
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API @API:native_claimed_items@.
pub(super) fn native_poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_input.poll(mod_id)
}
/// `use_hand`: a right-click with an item a mod claims starts its use for
/// that mod and nothing else happens. True when a mod took it.
pub(super) fn mod_use_start(&mut self) -> bool {
if self.guest_use.is_some() || self.using.is_some() {
return false;
}
// With no use, a mod use the game reset without telling it is over.
self.native_input.end();
let slot = self.held_slot();
let Some(item) = self.mod_held(slot) else {
return false;
};
let Some(id) = self.native_input.claimant(&item.id).map(str::to_owned) else {
return false;
};
self.native_input.start(&id, item, slot);
self.using = Some((slot, 0));
true
}
/// `tick_used_item` for a claimed item: counts the ticks held and ends
/// the use when the button comes up. True when the player's use is a
/// mod's; a mod use the game reset ends, and the player's own use goes on.
pub(super) fn mod_use_tick(&mut self, slot: usize, ticks: u32, released: bool) -> bool {
if !self.native_input.owns(Some((slot, ticks))) {
self.native_input.end();
return false;
}
let still_claimed = self.native_input.using.as_ref().is_some_and(|u| {
self.stack_in(slot)
.is_some_and(|s| self.native_input.claimant(&s.item) == Some(u.mod_id.as_str()))
});
if released || !still_claimed {
self.native_input.end();
self.using = None;
} else {
self.native_input.tick();
self.using = Some((slot, ticks + 1));
}
true
}
/// The player's use was reset (a respawn, a join, the hand's item
/// changed): a mod use under way ends and its mod hears `UseStop`.
pub(super) fn end_mod_use(&mut self) {
self.native_input.end();
}
}
#[cfg(test)]
mod input_tests {
use super::*;
fn stick(slot: u32) -> NativeHeldItem {
NativeHeldItem {
id: "minecraft:stick".into(),
count: 1,
slot,
custom_name: None,
custom_data: Some("{orbital:1b}".into()),
}
}
#[test]
fn items_are_claimed_by_the_first_mod_in_id_order() {
let mut input = ModInput::default();
let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
input.claim("zeta", &ids(&["orbital:remote", "minecraft:stick"]));
input.claim("alpha", &ids(&["minecraft:stick"]));
assert_eq!(input.claimant("minecraft:stick"), Some("alpha"));
assert_eq!(input.claimant("orbital:remote"), Some("zeta"));
assert_eq!(input.claimant("minecraft:dirt"), None);
input.claim("alpha", &[]);
assert_eq!(input.claimant("minecraft:stick"), Some("zeta"));
let many: Vec<String> = (0..100).map(|i| format!("m:item{i}")).collect();
input.claim("alpha", &many);
assert_eq!(input.claimant("m:item63"), Some("alpha"));
assert_eq!(input.claimant("m:item64"), None, "64 at most");
}
#[test]
fn events_wait_for_their_mod_up_to_a_limit() {
let mut input = ModInput::default();
for _ in 0..EVENTS_MAX + 10 {
input.push("a", NativeModEvent::UseStart(stick(0), 0));
}
input.push("b", NativeModEvent::UseStop(stick(0), 3));
assert_eq!(input.poll("a").len(), EVENTS_MAX);
assert!(input.poll("a").is_empty());
assert_eq!(input.poll("b"), vec![NativeModEvent::UseStop(stick(0), 3)]);
}
#[test]
fn a_claimed_use_is_the_mods_while_it_runs() {
let mut input = ModInput::default();
input.start("orbital", stick(2), 2);
assert!(input.owns(Some((2, 0))));
input.tick();
input.tick();
assert!(input.owns(Some((2, 2))));
input.end();
assert!(!input.owns(Some((2, 2))));
assert_eq!(
input.poll("orbital"),
vec![
NativeModEvent::UseStart(stick(2), 0),
NativeModEvent::UseStop(stick(2), 2),
]
);
}
#[test]
fn an_interrupted_claimed_use_leaves_the_next_use_alone() {
// A respawn ends the use: the mod hears it stop, and a vanilla use
// begun next in the same slot is not taken for the mod's.
let mut input = ModInput::default();
input.start("orbital", stick(0), 0);
input.tick();
input.end();
assert!(!input.owns(Some((0, 0))));
assert_eq!(
input.poll("orbital").last(),
Some(&NativeModEvent::UseStop(stick(0), 1))
);
// A reset the mod use never heard of (`Game.using` cleared alone):
// the player's next use, from 0 ticks or in another slot, is still
// not the mod's, and ending it then tells the mod once.
input.start("orbital", stick(0), 0);
input.tick();
input.tick();
assert!(!input.owns(None));
assert!(!input.owns(Some((0, 0))), "a vanilla use from 0 ticks");
assert!(!input.owns(Some((1, 2))), "another slot");
input.end();
input.end();
let events = input.poll("orbital");
assert_eq!(events.len(), 2);
assert_eq!(events[1], NativeModEvent::UseStop(stick(0), 2));
}
}
+185 -11
View File
@@ -298,6 +298,7 @@ use horizon_client::{
keybinds,
mobs::{Damage, Who},
mod_list::{self, Installed, NativePage},
music::{Music, MusicLayer},
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -1185,6 +1186,35 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The server is about to send its data to the players (NeoForge's
/// `OnDatapackSyncEvent`, its `sendRecipes`): push the recipe types
/// (`minecraft:smelting`, `mymod:crusher`) whose recipes your client half
/// needs. Horizon sends their final recipes — the world's data packs
/// merged, load conditions held — to every Horizon client when it joins
/// and again after each `/reload`; a vanilla client is sent nothing new.
/// Asked once per load, when the server's data tables are built, and the
/// same types go to every player: unlike NeoForge, which asks again for
/// each joining player, it can't differ between players. Push nothing and
/// nothing is sent; push nothing after a load that pushed some and the
/// clients go back to their own recipes. Server-side (the single
/// player's integrated server included). API @API:recipe_sync@.
pub(super) fn native_datapack_sync(&mut self, recipe_types: &mut Vec<String>) {
let _ = recipe_types;
}
/// The server's recipes of `recipe_types` arrived (NeoForge's
/// `RecipesReceivedEvent`): from now on the recipe helpers
/// (`native_recipes`, `native_recipes_of_type`) give the server's recipes
/// of those types in place of the window's own, so rebuild what you made
/// of them. It comes after a join, once the window has its player, and
/// after every `/reload`, on a server whose mods asked for them
/// (`native_datapack_sync`), single player included; with no types when
/// the server stopped sending them and the window is back on its own
/// recipes. A recipe the
/// window's own recipe book doesn't know has `book_id` -1. Client-side.
/// API @API:recipe_sync@.
pub(super) fn native_recipes_received(&mut self, recipe_types: &[String]) {
let _ = recipe_types;
}
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
/// the world's data packs, and the files mods added through
@@ -1323,6 +1353,12 @@ impl Game<'_> {
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
let _ = (key, pressed);
}
/// A real window key event after handling, including repeats and consumed shortcuts.
/// Receives the original physical/logical key, text, location, state and repeat flag.
/// Modifier bits use the screen hooks' GLFW encoding. Not cancellable.
pub(super) fn native_input_key(&mut self, event: &winit::event::KeyEvent, modifiers: u32) {
let _ = (event, modifiers);
}
/// Raw mouse button input on any screen, before dispatch; cancel skips dispatch and Post.
/// Button/modifiers use the same GLFW encoding as screen hooks. Synthetic clicks skip it.
pub(super) fn native_mouse_button_pre(
@@ -1342,6 +1378,20 @@ impl Game<'_> {
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
let _ = (steps, claimed);
}
/// Gameplay scrolling after whole steps accumulate, before hotbar/spectator handling.
/// `scroll` is scaled x/y, `accumulated` whole x/y steps, `buttons` left/middle/right,
/// and `cursor` the GUI position. Cancel skips gameplay scrolling; synthetic input skips it.
pub(super) fn native_mouse_scrolling(
&mut self,
scroll: [f64; 2],
accumulated: [i32; 2],
buttons: [bool; 3],
cursor: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
}
/// A command typed or clicked in chat, without its slash, before the
/// server and Horizon's commands see it: set `ran` when the mod ran it.
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
@@ -1377,6 +1427,18 @@ impl Game<'_> {
) {
let _ = (partial, camera, fog_type, rgb);
}
/// Final world fog, after the computed color hook, ordinary calculations and Horizon's fog
/// option, before upload. Title panoramas skip it. Invalid color or distance values retain the
/// original whole fog.
pub(super) fn native_render_fog(
&self,
partial: f32,
camera: [f64; 3],
fog_type: horizon_client::fog::FogType,
fog: &mut horizon_client::fog::FogData,
) {
let _ = (partial, camera, fog_type, fog);
}
/// The camera shake native mods ask for in this frame, `partial` ticks
/// past the last tick (0 to 1): add your pitch, yaw and roll in degrees
/// and your offset in blocks, along the camera's right, up and back, to
@@ -1414,13 +1476,6 @@ impl Game<'_> {
) {
let _ = (partial, mirrored, entity, distance);
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
@@ -1432,6 +1487,36 @@ impl Game<'_> {
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
let _ = (biome, colors);
}
/// Choose this client's music, once a client tick: `music` and `volume`
/// hold what vanilla chose (`Minecraft.getSituationalMusic` and
/// `getMusicVolume`: the biome's track, the creative or underwater one, a
/// boss's, the menu's; None for silence). Set `music` to a track of your
/// own, a sound event of your content mod's `sounds.json` such as
/// `Music::new("mymod:music.spring", 1200, 6000, false)` (the delays in
/// ticks between songs, and whether it cuts off a different song playing),
/// None to keep vanilla's quiet, or change `volume` (0 to 1) to fade it. API
/// @API:music@.
pub(super) fn native_music(&self, music: &mut Option<Music>, volume: &mut f32) {
let _ = (music, volume);
}
/// The tracks to play over (or instead of) the music on this client,
/// asked once a client tick: push a `MusicLayer` (a sound event, looped,
/// and its volume, 0 to 1) for each. A layer eases to a new volume over up
/// to two seconds, and one left out fades out and stops; layers that
/// first appear in the same tick start together, so list every stem of a
/// piece from the start, the silent ones at volume 0, and raise them (a
/// fuller arrangement as a village grows). Their sound events should be
/// `stream: true` in `sounds.json`. API @API:music@.
pub(super) fn native_music_layers(&self, layers: &mut Vec<MusicLayer>) {
let _ = layers;
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
/// one that pack is for. Asked once a frame while a pack is on; a value
/// that isn't finite reads as 0 (`native_shader_params_now`). API
/// @API:native_shader_params@.
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
let _ = (partial, params);
}
@@ -1725,6 +1810,19 @@ impl Game<'_> {
) {
let _ = (screen, button, modifiers, canceled);
}
/// Before a real held-button movement reaches screen drag handlers.
/// `screen.mouse` is the current GUI position; `delta` is its GUI-space movement.
/// Cancel keeps the new cursor position but skips all screen drag handlers.
pub(super) fn native_screen_mouse_dragged_pre(
&self,
screen: &NativeScreenView<'_>,
button: i32,
delta: [f32; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, button, delta, modifiers, canceled);
}
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
/// `screen.mouse` where the pointer is (so there is no position of its
@@ -1792,6 +1890,17 @@ impl Game<'_> {
) {
let _ = (screen, key, repeat, modifiers, canceled);
}
/// Before an open screen handles a named key's release (same screen gate as key presses).
/// Cancel bypasses screen handling; physical keys and mapped keyboard hold/toggle state release.
pub(super) fn native_screen_key_released_pre(
&self,
screen: &NativeScreenView<'_>,
key: &str,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, key, modifiers, canceled);
}
/// A character was typed at the open screen
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
@@ -1857,6 +1966,34 @@ impl Game<'_> {
image.height as f32,
]
}
pub(super) fn track_drag_button(&mut self, button: MouseButton, pressed: bool) {
// Any release ends the drag, as vanilla's `MouseHandler.onButton` clears `activeButton`:
// left down, right down, left up sends no drag while right is still held.
self.drag_button = pressed.then_some(button);
}
pub(super) fn screen_mouse_dragged(&self, previous: (f32, f32)) -> bool {
if self.pad_synthetic_input() || self.cursor == previous {
return false;
}
let Some(button) = self
.drag_button
.filter(|b| self.physical_buttons.contains(b))
else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_mouse_dragged_pre(
&screen,
super::glfw_button(button),
[self.cursor.0 - previous.0, self.cursor.1 - previous.1],
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
@@ -1904,6 +2041,22 @@ impl Game<'_> {
);
canceled
}
pub(super) fn screen_key_released(&self, key: KeyCode) -> bool {
let Some(name) = keybinds::key_name(key) else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_key_released_pre(
&screen,
name,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// Text on its way to a text box after the open screen's character hooks
/// (`native_screen_char_typed_pre` per printable character): the cancelled
/// characters are gone and every other one stays, control characters
@@ -2386,6 +2539,25 @@ impl Game<'_> {
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
let _ = (player, name);
}
/// The number of players with the mod container at `pos` of `dimension`
/// open went from `previous` to `viewers` (vanilla's
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
/// `onOpen`, back to 0 is `onClose`, any change `openerCountChanged`, so a
/// block can show itself open while `viewers` is not 0. Counted every
/// tick from the players' open menus, so a player who leaves without
/// closing (a lost connection, death, another dimension, out of reach, the
/// block gone) is heard too (`recheckOpeners`). Once for each change, with
/// no player's state swapped in. Server-side.
/// @API:mod_container_viewers@.
pub(super) fn native_mod_container_viewers(
&mut self,
dimension: &str,
pos: [i32; 3],
viewers: u32,
previous: u32,
) {
let _ = (dimension, pos, viewers, previous);
}
/// `player` died: where `ServerPlayer.die` starts, with the death
/// message's key when the game knows it (`death.attack.lava`; none for a
/// message it builds from the combat). It only hears. Server-side, for
@@ -3684,7 +3856,8 @@ impl Game<'_> {
/// nobody uses and outside a world. These are the server's table on the
/// server thread, a dedicated server or a LAN host; a client, the
/// single-player window included, sees only the content mods' recipes, not
/// the world's data packs'. API 36.
/// the world's data packs', unless a mod had the server send it the type
/// (`native_datapack_sync`). API 36.
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
if self.outside_world() {
return &[];
@@ -3743,9 +3916,10 @@ impl Game<'_> {
self.player.is_none() && self.options.execution_role.is_client()
}
/// Every item and recipe the game knows, made once (the same for every
/// call); none outside a world. They are the window's own: the world's
/// in single player, vanilla's on a server, which sends a client no
/// others.
/// call); none outside a world. They are the window's own (the content
/// mods' and vanilla's), with the server's recipes of the types a mod had
/// it send (`native_datapack_sync`) in their place; made again when those
/// arrive (`native_recipes_received`).
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
self.player.as_ref()?;
self.made_recipes()
@@ -1,10 +1,10 @@
//! Every item and recipe the game knows, made once for the mods that read
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
//! mods, docs/mods.md), and the recipe book's known displays; placing a
//! recipe and creative gifts. The recipes are the window's own data: the
//! world's in single player, vanilla's on a server (a client is sent no
//! others), while the known displays follow the server's book as the recipe
//! book does.
//! recipe and creative gifts. The recipes are the window's own data — the
//! content root's — with the server's recipes of the types its native mods
//! had it send (`horizon:recipe_content`) in their place, while the known
//! displays follow the server's book as the recipe book does.
use super::{
Game,
native_hooks::{
@@ -13,7 +13,7 @@ use super::{
},
};
use horizon_client::{
crafting::Ingredient,
crafting::{Ingredient, recipe_id},
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
@@ -22,10 +22,22 @@ use std::{collections::HashMap, sync::Arc};
/// The tables, made once, and the recipe book's known displays with the
/// book's version they are of.
/// The most recipes a server's recipe content may carry.
const MAX_SYNCED_RECIPES: usize = 1_000_000;
#[derive(Default)]
pub(super) struct RecipeTables {
tables: Option<Arc<Recipes>>,
known: (Option<u64>, Arc<[i32]>),
/// The server's recipes of the types its native mods sent (`horizon:
/// recipe_content`) over the window's own: what `Game::recipes` reads on
/// a client once they came.
synced: Option<horizon_client::crafting::Recipes>,
/// The recipe content's parts so far, until its last.
incoming: Vec<(String, serde_json::Value)>,
/// The types that came before the player did: the mods hear of them
/// once the helpers can answer (`recipes_received_now`).
received: Option<Vec<String>>,
}
/// Item id -> its index in [`Recipes::items`].
@@ -187,11 +199,10 @@ impl Game<'_> {
);
let mut out = Vec::new();
let cooking: HashMap<String, (u32, f32)> = self
.res
.recipes()
.cooking
.iter()
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
.map(|c| (recipe_id(&c.id), (c.time, c.experience)))
.collect();
for e in self.recipe_book_entries() {
let furnace = |c: Category| match c.name().split('_').next() {
@@ -243,7 +254,7 @@ impl Game<'_> {
}
for c in &self.recipes().cooking {
if c.kind == "minecraft:campfire_cooking" {
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
let mut r = recipe(recipe_id(&c.id), RecipeKind::Campfire);
r.group = c.group.clone();
r.slots = vec![index.any(&c.ingredient)];
r.results = index.of(&c.result).into_iter().collect();
@@ -320,3 +331,107 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// The server's recipes a client was sent, over its own; None on a
/// server and until a server sends any.
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
self.recipe_tables.synced.as_ref()
}
/// A new play session (`Login`): what the last server sent is gone.
pub(super) fn forget_synced_recipes(&mut self) {
self.recipe_tables.synced = None;
self.recipe_tables.incoming.clear();
self.recipe_tables.received = None;
self.recipe_tables.tables = None;
self.forget_book();
}
/// `native_recipes_received` for the types that came, once the window
/// has its player: the recipes of a join come before it, and the helpers
/// answer nothing until then. Once a frame.
pub(super) fn recipes_received_now(&mut self) {
if self.player.is_some()
&& let Some(types) = self.recipe_tables.received.take()
{
self.native_recipes_received(&types);
}
}
/// `OnDatapackSyncEvent.sendRecipes`: the recipe types the native mods
/// want their clients to have, from this load's tables, become what the
/// server sends its Horizon clients at a join and after `/reload`. Called
/// once a load's tables are built, before any player hears of them.
pub(super) fn sync_recipe_content(&mut self) {
use horizon_client::lan::recipes;
let mut types = Vec::new();
self.native_datapack_sync(&mut types);
types.sort();
types.dedup();
let sources = if types.is_empty() {
Vec::new()
} else {
horizon_client::crafting::Recipes::sources(&self.options.resources, &self.pack_files())
.unwrap_or_else(|e| {
log::warn!("Horizon: the recipes for the clients were not read: {e}");
Vec::new()
})
};
recipes::set_content(recipes::encode_content(
&types,
&sources,
recipes::sent_before(),
));
}
/// A `horizon:recipe_content` part the server sent the window. On the
/// last, the window's tables take the server's recipes of those types in
/// place of its own, and the mods hear of it (`RecipesReceivedEvent`).
pub(super) fn remote_recipe_content(&mut self, data: &[u8]) {
let part: horizon_client::lan::recipes::ContentPart = match serde_json::from_slice(data) {
Ok(part) => part,
Err(e) => {
log::warn!("Horizon: bad recipe content from the server: {e}");
return;
}
};
self.recipe_tables.incoming.extend(part.recipes);
// The server is trusted to end its parts; one that never does is
// dropped, not kept growing.
if self.recipe_tables.incoming.len() > MAX_SYNCED_RECIPES {
log::warn!("Horizon: the server sent more than {MAX_SYNCED_RECIPES} recipes; dropped");
self.recipe_tables.incoming = Vec::new();
return;
}
let Some(types) = part.last else {
return;
};
let received = std::mem::take(&mut self.recipe_tables.incoming);
// No type any more: back on the window's own recipes.
if types.is_empty() {
if self.recipe_tables.synced.take().is_some() {
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
return;
}
let root = &self.options.resources;
let overlay = horizon_world::loot::PackOverlay::NONE;
let built = horizon_client::crafting::Recipes::sources(root, &overlay).and_then(|own| {
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
all.extend(received);
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
});
match built {
Ok(recipes) => {
self.recipe_tables.synced = Some(recipes);
// What was made of the window's own recipes is made again.
self.recipe_tables.tables = None;
self.forget_book();
self.recipe_tables.received = Some(types);
self.recipes_received_now();
}
Err(e) => log::warn!("Horizon: the server's recipes were not taken: {e}"),
}
}
}
@@ -628,7 +628,8 @@ fn add_to(
/// screen as any change to their inventory does.
#[allow(dead_code)]
impl Game<'_> {
/// The main hand's stack: its item, count, and whether it is plain.
/// The main hand's stack: its item, count, and whether it is plain. On a
/// client, the player's own.
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
let s = self.inventory.get(self.selected)?.as_ref()?;
Some((s.item.to_string(), s.count, plain(s)))
+31
View File
@@ -148,6 +148,23 @@ impl Game<'_> {
self.ui.vanilla_root.clone()
}
}
/// Whether all active packs preserve vanilla's block models. The server
/// overlay sits above the local selection and must be included too.
pub(super) fn model_packs_are_vanilla(&self) -> bool {
self.settings.horizon.vanilla_models_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The leaf mode that is safe for the current local and server packs.
pub(super) fn effective_leaf_culling(&self) -> horizon_client::graphics::LeafCullingMode {
self.settings
.horizon
.leaf_culling_effective_with_server_packs(
self.settings.resource_packs.iter().map(String::as_str),
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
)
}
/// The packs the pack screen offers and the current selection
/// (`PackSelectionScreen` makes its `PackSelectionModel` from them).
pub(super) fn refresh_pack_lists(&mut self) {
@@ -355,11 +372,25 @@ impl Game<'_> {
.cem
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
// The random entity textures the new atlas packs: every mob chooses again.
*self
.res
.mob_data()
.random_textures
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
std::mem::take(&mut self.gui.atlas.random_textures);
for m in &mut self.mobs {
m.random_textures.forget_picks();
}
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
self.audio.reload(&root);
self.clear_lod();
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
self.streaming.leaf_culling = self.effective_leaf_culling();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
@@ -1416,6 +1416,7 @@ mod preview_tests {
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
+42 -22
View File
@@ -7,8 +7,8 @@ use horizon_client::{
mobs::{
Mob, Species, Who,
finalize::{
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
set_slot_enchantments, slot_enchantments, stored_drop_chance,
},
piglin::{ADMIRE, ATE_RECENTLY},
},
@@ -27,8 +27,9 @@ impl Game<'_> {
self.item_in_tag("minecraft:piglin_loved", item)
}
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
fn piglin_wanted(&self, item: &str) -> bool {
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
fn piglin_wanted(&self, m: &Mob, item: &str) -> bool {
self.piglin_loved(item)
|| !m.is_baby() && self.item_in_tag("minecraft:piglin_preferred_weapons", item)
}
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
/// to its slot, anything else to the main hand.
@@ -50,7 +51,7 @@ impl Game<'_> {
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
return true;
};
self.piglin_wanted(item) && !self.piglin_wanted(&current)
self.piglin_wanted(m, item) && !self.piglin_wanted(m, &current)
}
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
@@ -62,11 +63,13 @@ impl Game<'_> {
fn piglin_max_stack(&self, item: &str) -> u32 {
self.items().get(item).map_or(64, |i| i.max_stack)
}
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
/// the baby rule, which babies skip entirely here.
/// `PiglinAi.wantsToPickup`: babies ignore their own item tag, but can
/// pick up currency and other loved items just like adults.
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
let item = &*stack.item;
if self.item_in_tag("minecraft:piglin_repellents", item) {
if m.is_baby() && self.item_in_tag("minecraft:ignored_by_piglin_babies", item)
|| self.item_in_tag("minecraft:piglin_repellents", item)
{
return false;
}
let holds_loved = m
@@ -191,8 +194,7 @@ impl Game<'_> {
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin || !m.alive() || m.is_baby() || m.extra.using_item > 0
{
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
continue;
}
let pos = m.position;
@@ -302,8 +304,8 @@ impl Game<'_> {
true
}
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
/// roll of the bartering table, thrown toward the player; any other loved
/// item it picked up is kept (worn if it can be, else stored).
/// roll of the bartering table for adults. Babies equip the admired item,
/// or hold it in the main hand after stashing or throwing the old item.
pub(super) fn finish_admiring(&mut self, mob: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
@@ -311,22 +313,40 @@ impl Game<'_> {
let Some(item) = self.mobs[i].extra.off_hand.take() else {
return;
};
if self.mobs[i].is_baby() {
let stack = ItemStack::new(&item, 1);
if !self.piglin_equip(i, &stack) {
if let Some(old) = item_stack(&self.mobs[i], 0) {
if self.piglin_loved(&old.item) {
self.piglin_stash(i, old);
} else {
self.piglin_throw_items(i, vec![old]);
}
}
let m = &mut self.mobs[i];
set_item_stack(m, 0, stack);
set_drop_chance(m, 0, 2.0);
m.persistent = true;
}
return;
}
if &*item != "minecraft:gold_ingot" {
self.piglin_keep(i, ItemStack::new(&item, 1));
return;
}
let items = world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
);
self.piglin_throw_items(i, items);
}
/// `PiglinAi.throwItems`: barters and a baby's unwanted main-hand item
/// use the same player-directed throw.
fn piglin_throw_items(&mut self, i: usize, items: Vec<ItemStack>) {
let m = &self.mobs[i];
let from = [m.position.x, m.position.y + 1.3, m.position.z];
let items = if !m.is_baby() {
world_items!(self).loot_table(
"minecraft:gameplay/piglin_bartering",
false,
None,
&mut self.world.random,
)
} else {
vec![ItemStack::new(&item, 1)]
};
// BehaviorUtils.throwItem toward the nearest visible player
// (NEAREST_VISIBLE_PLAYER: within 16, alive, no spectators; the
// guests count as much as the host), else nearby.
@@ -507,6 +507,12 @@ impl Game<'_> {
)
.unwrap_or(Vec3::ZERO)
});
// Entity.move updates an authoritative living entity's ground flag
// even for a horizontal piston push. Keeping the old support flag
// would run Frost Walker while the stand is moving over open water.
if let Target::ArmorStand(i) = target {
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
@@ -1140,6 +1140,15 @@ impl Game<'_> {
});
self.potions.thrown = thrown;
for (stack, at, mob, block, age, owner) in landed {
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
// is heard at its centre, an entity at the hit.
let heard = if mob.is_some() {
[at.x, at.y, at.z]
} else {
[at.x.floor() + 0.5, at.y.floor() + 0.5, at.z.floor() + 0.5]
};
let thrower = owner.is_some().then_some(Who::Player);
self.projectile_event("minecraft:projectile_land", thrower, heard);
self.splash(&stack, at, mob, block, age, owner);
}
}
@@ -0,0 +1,75 @@
//! The window's side of the RAM limit (`horizon_client::ram_limit`): a look at the memory once a
//! second, the render distance and far terrain in use, and `/ramlimit`'s answer.
use super::Game;
use horizon_client::ram_limit::{self, Step};
use horizon_client::text::Text;
use std::time::Instant;
impl Game<'_> {
/// The render distance in use: the player's, unless memory holds it lower.
pub(super) fn render_distance_in_use(&self) -> i32 {
self.ram_limit.distance(self.settings.render_distance)
}
/// Once a second, on the window's game while a world is open.
pub(super) fn tick_ram_limit(&mut self) {
if !self.ticks.is_multiple_of(20) {
return;
}
let chosen = self.settings.render_distance;
let Some(limit) = ram_limit::limit() else {
if self.ram_limit.reset() {
self.apply_render_distance_in_use();
}
return;
};
let has_far = self.settings.horizon.lod_distance > 0;
let pressure = ram_limit::pressure(limit, self.fps);
let step = self
.ram_limit
.step(Instant::now(), pressure, chosen, has_far);
if !self.ram_limit.at_lowest(chosen, has_far) {
ram_limit::can_shrink();
}
let Some(step) = step else {
return;
};
self.apply_render_distance_in_use();
let said = match step {
Step::FarTerrainOff => "Far terrain is off to stay under the RAM limit.".to_owned(),
Step::FarTerrainBack => "Far terrain is back on.".to_owned(),
Step::Lowered(d) => {
format!("Render distance lowered to {d} to stay under the RAM limit.")
}
Step::Raised(d) if d >= chosen => format!("Render distance is back to {d}."),
// Going back up step by step needs no line for each step.
Step::Raised(_) => return,
};
log::info!("Horizon: {said} {pressure:?}");
self.system_message(Text::literal(said.as_str()));
}
/// The streaming radius follows the distance in use, within what the server sends; the server
/// hears of it with the next `ClientInformation` or `set_distances`.
fn apply_render_distance_in_use(&mut self) {
let distance = self.render_distance_in_use();
self.streaming.radius = match &self.remote {
Some(r) if r.logged_in => distance.min(r.view_distance + 1).max(2),
_ => distance,
};
}
/// `/ramlimit`, answered here on any server. False: another command.
pub(super) fn ram_limit_command(&mut self, command: &str) -> bool {
let Some(answer) = ram_limit::run(command) else {
return false;
};
self.system_message(Text::literal(answer.as_str()));
let (in_use, chosen) = (self.render_distance_in_use(), self.settings.render_distance);
if in_use < chosen {
let line = format!("Render distance is {in_use} for now; yours is {chosen}.");
self.system_message(Text::literal(line.as_str()));
}
true
}
}
@@ -0,0 +1,64 @@
//! OptiFine random entity textures: each mob's variants, chosen when this window first sees it
//! and checked again once a second (`horizon_client::mobs::random_textures`).
use super::Game;
use horizon_client::mobs::random_textures::{Seen, choose, seed};
use horizon_core::geometry::Vec3;
use horizon_optifine::random::Weather;
impl Game<'_> {
pub(super) fn tick_random_textures(&mut self) {
let res = self.res.clone();
let textures = res
.mob_data()
.random_textures
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if textures.is_empty() {
return;
}
let tick = self.world.game_time;
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if !m.random_textures.due(tick, m.id) {
continue;
}
let seed = seed(m, self.persist.uuids.get(&m.id).copied());
let seen = (!m.random_textures.has_seen()).then(|| self.seen_at(m.position));
choose(&mut self.mobs[i], &textures, seen, seed);
}
}
/// What OptiFine reads of a mob's surroundings when it first appears.
fn seen_at(&self, at: Vec3) -> Seen {
let (x, y, z) = (
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let block = |y| {
let state = self.pack.assets.registry.state(self.world.block(x, y, z))?;
let properties = state
.properties
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some((state.name.clone(), properties))
};
let weather = &self.world.weather;
Seen {
biome: self.biome_name_at(at),
y,
blocks: [block(y - 1), block(y)].into_iter().flatten().collect(),
moon_phase: self.world.day_time.div_euclid(24_000).rem_euclid(8) as i32,
day_time: self.world.day_time.rem_euclid(24_000) as i32,
weather: if weather.thundering {
Weather::Thunder
} else if weather.raining {
Weather::Rain
} else {
Weather::Clear
},
}
}
}
@@ -369,6 +369,11 @@ impl Game<'_> {
self.recipe_ui.client.displays(),
)
}
/// The book's model is made again from `Game::recipes` when next needed:
/// the server's recipes arrived, or a new session began.
pub(super) fn forget_book(&mut self) {
self.recipe_ui.book = None;
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.recipes(), self.items());
+57 -8
View File
@@ -794,7 +794,7 @@ impl Game<'_> {
// sent again when one of those options changes.
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
.client_information(client_view_distance(self.render_distance_in_use()));
if local.is_none() && !self.options.split_guest {
horizon_client::crash::set_detail(
"Server",
@@ -855,7 +855,7 @@ impl Game<'_> {
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.settings.render_distance;
streaming.radius = self.render_distance_in_use();
self.streaming = streaming;
self.world = World::new(self.res.facts().air, 0);
self.save = Arc::new(SaveDir::new(
@@ -882,6 +882,7 @@ impl Game<'_> {
self.container = None;
self.carried = None;
self.using = None;
self.end_mod_use();
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
@@ -2873,6 +2874,10 @@ impl Game<'_> {
C::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
self.remote_native_net(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
}
// PingDebugMonitor.onPongReceived.
C::PongResponse(p) => {
let delay = self.debug_millis() - p.time;
@@ -2880,6 +2885,7 @@ impl Game<'_> {
}
C::Login(p) => {
self.test_highlights.clear();
self.forget_synced_recipes();
self.level.hardcore = p.hardcore;
// handleLogin: startWaitingForNewLevel with Reason.OTHER, which
// replaces ServerReconfigScreen.
@@ -2906,7 +2912,7 @@ impl Game<'_> {
r.show_death_screen = p.show_death_screen;
}
self.streaming.radius =
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
self.render_distance_in_use().min(p.chunk_radius + 1).max(2);
// handleLogin: startWaitingForNewLevel(.., Reason.OTHER).
self.enter_remote_dimension(
&p.spawn,
@@ -2915,7 +2921,7 @@ impl Game<'_> {
);
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
.client_information(client_view_distance(self.render_distance_in_use()));
if let Some(r) = self.remote.as_mut() {
r.sent_info = info.clone();
}
@@ -2927,6 +2933,7 @@ impl Game<'_> {
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.end_mod_use();
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
// rides nothing: the server pairs a vehicle again with its
// passengers. A dimension change keeps the vehicle it travels in.
@@ -4363,6 +4370,15 @@ impl Game<'_> {
p.unsigned_content.as_ref(),
&line,
);
// ChatListener.showMessageToPlayer: `Minecraft.isBlocked` hides the
// message of a player hidden in the Social Interactions screen; it
// counts as processed, not shown.
if self.social_hides(p.sender) {
if let Some(signature) = &signature {
self.mark_message_processed(signature, false);
}
return Ok(());
}
self.handle_player_chat(line, signature, trust, narration);
}
C::DisguisedChat(p) => {
@@ -4528,7 +4544,7 @@ impl Game<'_> {
}
}
C::SetChunkCacheRadius(p) => {
self.streaming.radius = self.settings.render_distance.min(p.distance + 1).max(2);
self.streaming.radius = self.render_distance_in_use().min(p.distance + 1).max(2);
if let Some(r) = self.remote.as_mut() {
r.view_distance = p.distance;
}
@@ -5415,9 +5431,10 @@ impl Game<'_> {
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
self.tick_ram_limit();
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
.client_information(client_view_distance(self.render_distance_in_use()));
if let Some(r) = self.remote.as_mut()
&& r.joined
&& r.sent_info != info
@@ -5461,6 +5478,7 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_random_textures();
// Breeze.tick's block dust: drawn by each client for the breezes it sees.
if running {
self.tick_breeze_effects();
@@ -6334,6 +6352,8 @@ impl Game<'_> {
collision,
false,
&self.res.motion().fluids,
// The server decides what an item rests on; this copy only moves between updates.
&[],
);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
@@ -6858,6 +6878,10 @@ impl Game<'_> {
if self.hands_busy() {
return;
}
// An item a mod claims: its use is the mod's, and the server never hears of it.
if self.mod_use_start() {
return;
}
// Resolve the crosshair once. A passing entity interaction keeps
// that target and may only fall back to using the item itself.
let entity = self.entity_targeted();
@@ -7078,7 +7102,7 @@ impl Game<'_> {
self.open_block_editor_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
if result == InteractionResult::Success {
if result.should_swing() {
self.with_hand(hand, Game::swing_arm);
}
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
@@ -7469,6 +7493,11 @@ impl Game<'_> {
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
return false;
};
// A mod's claimed item is never used on the server; its own use
// starts from the main hand only (`mod_use_start`).
if self.native_input.claimant(&held.item).is_some() {
return false;
}
let seq = self.next_sequence();
self.send(S::UseItem(play::UseItem {
hand: hand_id(hand),
@@ -7522,6 +7551,11 @@ impl Game<'_> {
}
return succeeded;
}
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
// hit; the server alone tests LiquidBlock and the placement permission.
if held.item.ends_with("_spawn_egg") {
return self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
@@ -7734,6 +7768,8 @@ impl Game<'_> {
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
// A mod's use the game reset (the server stopping the use...).
self.end_mod_use();
return;
};
let stack = self.stack_in(slot).cloned();
@@ -7745,12 +7781,17 @@ impl Game<'_> {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
self.end_mod_use();
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A mod's item: held until the button lets go, counted for the mod.
if self.mod_use_tick(slot, ticks, !self.right || !in_hand) {
return;
}
// BrushItem.onUseTick: with no block aimed the use ends (the server
// ends it too); a stroke shows its dust. Its sounds are the server's.
if stack
@@ -8261,7 +8302,10 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
self.send_command(command);
// The player's own RAM limit, which no server has a say in.
if !self.ram_limit_command(command) {
self.send_command(command);
}
} else {
self.send_signed_chat(message);
}
@@ -9497,6 +9541,11 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
self.0.using.is_some()
}
fn release_using(&mut self) {
if let Some((slot, ticks)) = self.0.using
&& self.0.mod_use_tick(slot, ticks, true)
{
return;
}
self.0.release_remote_use();
}
fn start_attack(&mut self) -> bool {
@@ -143,6 +143,7 @@ impl Game<'_> {
if self.title.is_none() {
fog.color =
self.native_fog_color_now(self.clock.fraction() as f32, at, fog_type, fog.color);
fog = self.native_fog_now(self.clock.fraction() as f32, at, fog_type, fog);
}
fog
}
@@ -161,6 +162,38 @@ impl Game<'_> {
color
}
}
fn native_fog_now(
&self,
partial: f32,
camera: [f64; 3],
fog_type: FogType,
original: FogData,
) -> FogData {
let mut fog = original;
self.native_render_fog(partial, camera, fog_type, &mut fog);
let distances = [
fog.environmental_start,
fog.environmental_end,
fog.render_distance_start,
fog.render_distance_end,
fog.sky_end,
fog.cloud_end,
];
if fog
.color
.iter()
.all(|v| v.is_finite() && (0.0..=1.0).contains(v))
&& distances.iter().all(|v| v.is_finite())
&& fog.environmental_end > fog.environmental_start
&& fog.render_distance_end > fog.render_distance_start
&& fog.sky_end >= 0.0
&& fog.cloud_end >= 0.0
{
fog
} else {
original
}
}
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
fn fog_night_vision(&self) -> f32 {
+1 -1
View File
@@ -821,7 +821,7 @@ impl Game<'_> {
}
/// The host and guests that have entered the world, rebuilt from live state.
fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
let mut players = vec![me];
players.extend(guests.iter().filter(|g| g.placed).map(|g| {
let mut player = TabPlayer::new(g.uuid, &g.name);
+12 -10
View File
@@ -24,6 +24,7 @@ impl Screen {
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::Social
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
@@ -118,8 +119,7 @@ fn pause_buttons(
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the first two open their link through
/// `ConfirmLinkScreen`; player reporting (`SocialInteractionsScreen`, not
/// ported) stays inactive.
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
const FRIENDS_ICON: usize = 2;
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
const BUGS_LINK: &str = "https://aka.ms/snapshotbugs?ref=game";
@@ -143,7 +143,7 @@ const ICON_LABELS: [&str; 4] = [
/// button always, friends when the friends list is shown.
fn icon_active(icon: usize, friends: bool) -> bool {
match icon {
0 | 1 => true,
0 | 1 | 3 => true,
FRIENDS_ICON => friends,
_ => false,
}
@@ -362,6 +362,8 @@ impl Game<'_> {
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
// PauseScreen: `new SocialInteractionsScreen(this)`.
15 => self.open_social(true),
30 => self.open_split_settings(),
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
@@ -403,8 +405,9 @@ impl Game<'_> {
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
// The report bugs, feedback and player reporting icons (ids 12, 13 and 15).
[0, 1, 3]
.into_iter()
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
@@ -916,16 +919,15 @@ mod tests {
fn pause_icons_report_bugs_and_feedback_are_active_like_vanilla() {
use super::{FRIENDS_ICON, icon_active};
// PauseScreen.createPauseMenu: the bug and feedback buttons are live,
// friends follows its list, player reporting waits for its screen.
assert!(icon_active(0, false) && icon_active(1, false));
// friends follows its list, player reporting opens the social screen.
assert!(icon_active(0, false) && icon_active(1, false) && icon_active(3, false));
assert!(icon_active(FRIENDS_ICON, true) && !icon_active(FRIENDS_ICON, false));
assert!(!icon_active(3, true));
}
#[test]
fn pause_keyboard_focus_includes_the_active_icon_buttons() {
assert_eq!(pause_icon_focus_ids(false), [12, 13]);
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10]);
assert_eq!(pause_icon_focus_ids(false), [12, 13, 15]);
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10, 15]);
}
#[test]
+244
View File
@@ -0,0 +1,244 @@
//! `SocialInteractionsScreen` and `PlayerSocialManager` on the game side: the players Horizon
//! knows (the tab list's), the ones whose chat the player hid, the screen's page, search and
//! scroll, and what hiding does to chat (`Minecraft.isBlocked`). The screen is
//! `social_screen`. Blocking is Mojang's service (`UserApiService.isBlockedPlayer`), which
//! Horizon does not offer: nobody is blocked, as in vanilla's offline mode.
use super::{Game, Screen};
use horizon_client::{
social_screen::{self, Action, Entry, Page, SocialView},
tab_list::TabPlayer,
text::Text,
ui::EditBox,
};
use std::collections::{HashMap, HashSet};
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
};
/// `PlayerSocialManager.hiddenPlayers` and the screen's state.
pub(super) struct Social {
pub hidden: HashSet<u128>,
/// `discoveredNamesToUUID`, the other way: the names of the players seen, so a hidden player
/// who left is still listed by name.
names: HashMap<u128, String>,
page: Page,
search: EditBox,
scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
}
impl Default for Social {
fn default() -> Self {
let mut search = EditBox::new(0.0, 0.0, 200.0, 15.0);
search.max_length = 16;
Self {
hidden: HashSet::new(),
names: HashMap::new(),
page: Page::All,
search,
scroll: 0.0,
from_pause: false,
}
}
}
impl Game<'_> {
/// The players of the level, as the tab list shows them, and the local player's id.
fn social_players(&mut self) -> (Vec<TabPlayer>, u128) {
match self.remote_tab_players() {
Some((players, _, _)) => {
let me = self.remote.as_ref().map_or(0, |r| r.profile());
(players, me)
}
None => {
let uuid = self.level.player_uuid.map_or(0, |u| {
u.iter()
.fold(0u128, |a, &w| (a << 32) | u128::from(w as u32))
});
let mut me = TabPlayer::new(uuid, super::chat::PLAYER_NAME);
me.skin = "entity/player/wide/steve".into();
let guests = self.lan.as_ref().map_or(&[][..], |h| &h.hub.guests);
(super::scoreboard::local_tab_players(me, guests), uuid)
}
}
}
/// `Minecraft.isBlocked`: whether a player's chat is hidden (never the player's own).
pub(super) fn social_hides(&mut self, id: u128) -> bool {
if !self.social.hidden.contains(&id) {
return false;
}
let (_, me) = self.social_players();
id != me
}
/// `new SocialInteractionsScreen(lastScreen)`: it opens on All, with an empty search.
pub(super) fn open_social(&mut self, from_pause: bool) {
self.social.page = Page::All;
self.social.search.set_value("");
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
self.capture(false);
}
pub(super) fn close_social(&mut self) {
if self.social.from_pause {
self.screen = Screen::Paused;
} else {
self.screen = Screen::Playing;
self.capture(true);
}
}
/// The page's rows, sorted and filtered as `SocialInteractionsPlayerList` does.
fn social_view(&mut self) -> SocialView {
let (players, me) = self.social_players();
for p in &players {
self.social.names.insert(p.uuid, p.name.clone());
}
let hidden = self.social.hidden.clone();
let search = self.social.search.value.to_lowercase();
let mut entries: Vec<Entry> = Vec::new();
let online = |id: u128| players.iter().find(|p| p.uuid == id);
let page = self.social.page;
let ids: Vec<u128> = match page {
Page::All => players.iter().map(|p| p.uuid).collect(),
Page::Hidden => hidden.iter().copied().collect(),
// `UserApiService.isBlockedPlayer`: nobody, without the service.
Page::Blocked => Vec::new(),
};
for id in ids {
let (name, skin, removed) = match online(id) {
Some(p) => (p.name.clone(), p.skin.clone(), false),
None => (
self.social.names.get(&id).cloned().unwrap_or_default(),
horizon_client::tab_list::default_skin(id).to_owned(),
true,
),
};
if !search.is_empty() && !name.to_lowercase().contains(&search) {
continue;
}
let skin = self.face_skin(id, &skin);
entries.push(Entry {
id,
name,
skin,
hidden: hidden.contains(&id),
removed,
local: id == me,
});
}
social_screen::sort_entries(&mut entries);
let count = players.len();
let server = self.social_server_name();
let key = if count > 1 {
"gui.socialInteractions.server_label.multiple"
} else {
"gui.socialInteractions.server_label.single"
};
let args = [Text::literal(server), Text::literal(count.to_string())];
let max = social_screen::max_scroll(self.gui_size().1, entries.len());
self.social.scroll = self.social.scroll.clamp(0.0, max);
SocialView {
page,
search: self.social.search.value.clone(),
entries,
server_label: Text::translate(key, &args),
scroll: self.social.scroll,
}
}
/// The server's name: a local world's name, a server's entry in the list.
fn social_server_name(&self) -> String {
match (&self.integrated, self.server_address()) {
(Some(server), _) => server.world_name.clone(),
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
_ => self.level.name.clone(),
}
}
pub(super) fn draw_social_screen(&mut self, batch: &mut horizon_client::hud::HudBatch) {
self.flush_skin_slots();
let view = self.social_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("social", social_screen::TITLE)
});
social_screen::draw(
batch,
&self.gui.atlas,
self.cursor,
&view,
&mut self.social.search,
true,
);
}
pub(super) fn social_click(&mut self) {
let view = self.social_view();
let Some(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
return;
};
self.audio.play_button_click();
match action {
Action::Page(page) => {
self.social.page = page;
self.social.scroll = 0.0;
}
Action::Hide(id) | Action::Show(id) => {
let hiding = matches!(action, Action::Hide(_));
let name = self.social.names.get(&id).cloned().unwrap_or_default();
if hiding {
self.social.hidden.insert(id);
} else {
self.social.hidden.remove(&id);
}
// `PlayerEntry.onHiddenOrShown`: a line in the chat, and the narrator says it.
let key = if hiding {
"gui.socialInteractions.hidden_in_chat"
} else {
"gui.socialInteractions.shown_in_chat"
};
let line = Text::translate(key, &[Text::literal(name)]);
self.say_system_now(&line);
self.system_message(line);
}
Action::Done => self.close_social(),
}
}
/// `AbstractSelectionList.mouseScrolled`: a row's height and a bit a notch.
pub(super) fn social_scroll(&mut self, delta: f32) {
self.social.scroll -= delta * 18.0;
// `social_view` clamps it to the list.
let _ = self.social_view();
}
/// The search box has the focus from the start (`setInitialFocus`): typing fills it.
pub(super) fn social_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
}
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
match key {
KeyCode::Escape => self.close_social(),
_ => {
// `EditBox.keyPressed`: Backspace, Delete, the arrows, Home and End.
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
if !self.social.search.key(key, ctrl, shift)
&& self.types_text()
&& let Some(text) = event.text.as_deref()
{
self.social_type(text);
}
self.social.scroll = 0.0;
}
}
}
pub(super) fn social_backspace(&mut self) {
self.social.search.key(KeyCode::Backspace, false, false);
self.social.scroll = 0.0;
}
/// Characters into the search box (`searchBox.setMaxLength(16)`).
pub(super) fn social_type(&mut self, text: &str) {
let text: String = text.chars().filter(|c| !c.is_control()).collect();
self.social.search.insert_text(&text);
self.social.scroll = 0.0;
}
}
+1
View File
@@ -284,6 +284,7 @@ impl<'a> Split<'a> {
for button in std::mem::take(&mut game.physical_buttons) {
game.extras_mouse(button, false);
}
game.drag_button = None;
game.modifiers = Default::default();
game.left = false;
game.right = false;
+18 -1
View File
@@ -629,6 +629,12 @@ impl Game<'_> {
};
self.thrower_hit(t.guest, &victim, direct, damage);
}
// Projectile.onHit: the landing is heard, at the hit.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[t.position.x, t.position.y, t.position.z],
);
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
@@ -647,9 +653,20 @@ impl Game<'_> {
// `AbstractArrow.onHitBlock` on a hit.
let from = t.position;
let flight = t.velocity;
if let Some((stop, _)) =
if let Some((stop, block)) =
clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight)
{
// Projectile.onHit on a block: PROJECTILE_LAND at the
// block's centre.
self.projectile_event(
"minecraft:projectile_land",
Some(Who::Player),
[
f64::from(block.x) + 0.5,
f64::from(block.y) + 0.5,
f64::from(block.z) + 0.5,
],
);
if t.stack.enchantment_level("minecraft:channeling") > 0 {
self.channel_rod_hit(from, flight, t.guest);
}
@@ -52,10 +52,43 @@ impl WorldRequest {
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
horizon_client::structure_blocks::loot_data(
&options.resources,
&horizon_world::loot::PackOverlay::NONE,
);
let loot = || {
horizon_client::structure_blocks::loot_data(
&options.resources,
&horizon_world::loot::PackOverlay::NONE,
);
};
// Bone meal's and saplings' features, over the plain worldgen bundle.
let live_features = || growth::install_live_features(res, pack, options);
if res.threads() <= 1 {
loot();
let prepared = self.prepare_world(res, pack, options, settings, upgrade)?;
live_features();
return Ok(prepared);
}
// Neither needs the world: they load beside it (the generator's decoration keeps one
// core busy) and are done before it opens. A bundle both read loads once.
std::thread::scope(|scope| {
let loot = scope.spawn(loot);
let live_features = scope.spawn(live_features);
let prepared = self.prepare_world(res, pack, options, settings, upgrade);
for helper in [loot, live_features] {
helper
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
}
prepared
})
}
fn prepare_world(
self,
res: &Resources,
pack: &PackResources,
options: &Options,
settings: &Settings,
upgrade: &horizon_world::datafix::progress::UpgradeProgress,
) -> Result<PreparedWorld> {
let Self {
save_root,
folder,
@@ -227,8 +260,6 @@ impl WorldRequest {
};
}
growth::install_live_features(res, pack, options);
// Persist new metadata on the worker too, before publishing the world.
if !save_root.join("level.dat").exists() {
level.write(&save_root)?;
@@ -788,6 +819,9 @@ pub(super) fn make_streaming(
streaming.biome_blend = settings.biome_blend;
streaming.smooth_lighting = settings.smooth_lighting;
streaming.cutout_leaves = settings.cutout_leaves;
streaming.leaf_culling = settings
.horizon
.leaf_culling_effective(&settings.resource_packs);
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.sky_light = dim != Dim::Nether;
streaming.nether_shading = dim == Dim::Nether;
+1 -1
View File
@@ -157,7 +157,7 @@ impl Hub {
};
if ac.config.runs(Check::Prediction) {
let g = &mut self.guests[i];
let (input, sprinting) = (g.input, g.sprinting);
let (input, sprinting) = (g.input, g.anticheat.sprinting);
g.anticheat
.record
.get_or_insert_default()
+26
View File
@@ -939,6 +939,11 @@ impl Hub {
}));
// PlayerList.placeNewPlayer: ClientboundUpdateRecipesPacket.
g.send(&raw(ids::UPDATE_RECIPES, self.sync.recipes.clone()));
// OnDatapackSyncEvent.sendRecipes: the recipes the native mods asked
// for, to a Horizon client only.
if g.brand == "horizon" {
send_recipe_content(g);
}
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
self.send_permission_level(i);
self.send_initial_recipe_book(i);
@@ -2553,6 +2558,9 @@ impl Hub {
continue;
}
self.guests[i].send(&raw(ids::UPDATE_RECIPES, sync.recipes.clone()));
if self.guests[i].brand == "horizon" {
send_recipe_content(&self.guests[i]);
}
self.send_initial_recipe_book(i);
}
}
@@ -2651,6 +2659,7 @@ impl Hub {
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
let g = &mut self.guests[i];
g.sprinting = sprinting;
g.anticheat.sprinting = sprinting;
if let Some(m) = g.motion.as_mut() {
let _ = m.set_sprint_modifier(sprinting);
}
@@ -3419,6 +3428,10 @@ impl Hub {
spawn: info,
keep: 0,
}));
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
// says STOP.)
g.anticheat.sprinting = false;
// The respawned player is a new one, without effects; so is the
// client's (ClientPacketListener.handleRespawn).
g.effects = Default::default();
@@ -3635,6 +3648,19 @@ fn invalid_movement() -> Tag {
translatable("multiplayer.disconnect.invalid_player_movement", &[])
}
/// The recipe content the native mods asked for (`lan::recipes::content`),
/// to one Horizon client: none when no mod asked.
fn send_recipe_content(g: &Guest) {
for data in super::recipes::content().iter() {
g.send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: super::recipes::CONTENT_CHANNEL.into(),
data: data.clone(),
},
));
}
}
fn raw(id: i32, payload: Vec<u8>) -> C {
C::Unknown(id, payload)
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub mod hub;
pub mod menu;
pub mod playerdata;
pub mod prediction;
mod recipes;
pub mod recipes;
pub mod scoreboard;
pub mod tracking;
pub mod waypoints;
+122 -5
View File
@@ -196,6 +196,10 @@ pub struct Replay {
/// Ticks the client's jump delay is unknown: under an excuse the replay may have jumped where the
/// client couldn't (on a block it hadn't received), or the other way round.
jump_unsure: u8,
/// The last tick played: whether the client said it sprinted, and whether its keys pushed it
/// forward (for [`Replay::sprints`]).
claimed: bool,
forward: bool,
/// Ticks in a row within the tolerance, at most [`STEADY`].
steady: u8,
/// The velocity the client's last tick left it with, less the replay's.
@@ -230,6 +234,8 @@ impl Replay {
used: server.item_use_speed,
use_window: 0,
jump_unsure: 0,
claimed: false,
forward: false,
steady: STEADY,
missed: Vec3::ZERO,
meeting: None,
@@ -419,7 +425,7 @@ impl Replay {
}
// ClientPacketListener.handleSetEntityData: the flag alone. A sprint the tick goes on
// with is then started again (`MotionWorld::replayed_tick`), its modifier with it, as
// LocalPlayer.aiStep does when the sprint key is held.
// LocalPlayer.aiStep does when the sprint key is held ([`Replay::sprints`]).
Change::Sprinting(sprinting) => body.sprinting = sprinting,
Change::Effects => body.status = server.status.clone(),
// `sync` takes them every tick.
@@ -427,6 +433,18 @@ impl Replay {
}
}
/// Whether the client sprints in `tick`: as it said (ServerboundPlayerCommandPacket), but for a
/// sprint the server stopped (its flag, [`Change::Sprinting`]) that the client says goes on. That
/// client never said STOP: its LocalPlayer.aiStep started the sprint again, which takes the sprint
/// key held, or forward pressed again (a double tap). Without either it would have stopped and
/// said so: one that sprints on is played walking, until it holds the key or says STOP.
fn sprints(&mut self, tick: &ClientTick, keys: Keys) -> bool {
let restarted = tick.sprinting && self.claimed && !self.body.sprinting;
self.claimed = tick.sprinting;
let forward = std::mem::replace(&mut self.forward, keys.direction()[1] > 1.0e-5);
tick.sprinting && (!restarted || keys.sprint || !forward)
}
/// Plays `tick` again. `server` is the server's copy of the player, for what the server decides
/// (abilities, effects, attributes); `run` moves a body one client tick with these keys and
/// sprinting (`MotionWorld::replayed_tick`). None when no candidate could be played.
@@ -458,6 +476,7 @@ impl Replay {
|| self.use_window > 0 && server.item_use_speed.is_none() && used.is_some();
let said = tick.position.map(|p| Vec3::new(p[0], p[1], p[2]));
let keys = keys(tick.input);
let sprinting = self.sprints(tick, keys);
let last = self.said;
let offset = |body: &PlayerMotion| match said {
Some(p) => body.position.distance(p),
@@ -475,7 +494,7 @@ impl Replay {
};
let (offset, candidate) = if ![1, 2, 4, 8].into_iter().any(unknown) {
// Nothing to try (most ticks): the body itself plays the tick.
run(&mut self.body, keys, tick.sprinting).ok()?;
run(&mut self.body, keys, sprinting).ok()?;
(offset(&self.body), 0)
} else {
let mut best: Option<(f64, u8, Box<PlayerMotion>)> = None;
@@ -495,7 +514,7 @@ impl Replay {
if let Some(met) = meeting.filter(|_| candidate & 8 != 0) {
body.velocity = body.velocity.add_vector(met);
}
if run(&mut body, keys, tick.sprinting).is_err() {
if run(&mut body, keys, sprinting).is_err() {
continue;
}
let off = offset(&body);
@@ -704,6 +723,9 @@ mod tests {
/// sprint `.1`: the shared flags with that sprinting bit, and the movement speed with the
/// sprint modifier or without ([`Change::Sprinting`], [`Change::Attributes`]).
server_sprint: Vec<(usize, bool)>,
/// The client ignores the sprinting flag and movement speed the server sends (a cheat that
/// keeps its sprint through a hit).
keep_sprint: bool,
/// The first ticks the client is on its loading screen: it ends them without moving or
/// sending a move.
frozen: usize,
@@ -724,6 +746,7 @@ mod tests {
blocks: Vec::new(),
attributes: None,
server_sprint: Vec::new(),
keep_sprint: false,
frozen: 0,
item_use: None,
}
@@ -832,8 +855,10 @@ mod tests {
sprint: Some(sprint),
},
);
client.sprinting = sprint;
client.set_sprint_modifier(sprint).unwrap();
if !run.keep_sprint {
client.sprinting = sprint;
client.set_sprint_modifier(sprint).unwrap();
}
}
record.pong(tick, tick);
let before = client.position;
@@ -1273,6 +1298,98 @@ mod tests {
assert!(counted.len() >= 10, "{} of 18: {counted:?}", counted.len());
}
/// A sprint the server stopped after a hit (Player.causeExtraKnockback), which the client's own
/// attack on a mob doesn't: the server's flag and movement speed reach the client on tick 14.
/// Forward is held throughout, the sprint key for the first 10 ticks, and for the rest if `key`.
/// The client's own commands say what it does: with the key it starts the sprint again in its
/// next aiStep and says nothing (the live server's `ground, keys forward sprint` flags, where
/// the record took the server's player and played the rest of the sprint as a walk); without it
/// it stops and says STOP. `water`: the same swimming across a pool, toward its surface.
fn sprint_hit(key: bool, water: bool) -> Run {
let sprint = if key { 64 } else { 0 };
let mut script = vec![(1 | 64, 0.0); 10];
script.extend(vec![(1 | sprint, 0.0); 40]);
let mut blocks = Vec::new();
if water {
for x in 14..=26 {
for y in 5..=7 {
for z in 14..=40 {
blocks.push(([x, y, z], "minecraft:water"));
}
}
}
}
Run {
script,
pitch: if water { -8.0 } else { 0.0 },
blocks,
server_sprint: vec![(14, false)],
..Run::walk()
}
}
#[test]
fn a_sprint_the_server_stopped_after_a_hit_is_the_clients() {
for (key, water) in [(true, false), (false, false), (true, true), (false, true)] {
let Some(steps) = steps_in(sprint_hit(key, water), |_, _, honest| honest, None) else {
return;
};
for (i, s) in steps.iter().enumerate() {
assert!(
s.played.offset <= EXACT && s.over == Over::default(),
"key {key}, water {water}, tick {i}: {:?}",
s.played
);
}
// The client sprints on after the hit only with the key.
let last = steps.last().unwrap().played.state;
assert_eq!(
last & Played::SPRINTING != 0,
key,
"water {water}: {last:b}"
);
}
}
/// A client that sprints on through the server's stop without the sprint key, which vanilla's
/// starts again only with it, is played walking from the tick the stop reaches it; one that
/// holds the key and moves a twentieth of a block a tick too far counts from there too.
#[test]
fn a_sprint_kept_without_the_key_or_sped_up_after_a_hit_counts() {
for water in [false, true] {
let kept = Run {
keep_sprint: true,
..sprint_hit(false, water)
};
let fast = |i: usize, _, honest: Vec3| {
honest.add_vector(Vec3::new(0.0, 0.0, if i >= 14 { 0.05 } else { 0.0 }))
};
for (name, steps) in [
("kept", steps_in(kept, |_, _, honest| honest, None)),
("fast", steps_in(sprint_hit(true, water), fast, None)),
] {
let Some(steps) = steps else {
return;
};
let counted: Vec<usize> = steps
.iter()
.enumerate()
.filter(|(_, s)| s.over.ticks > 0)
.map(|(i, _)| i)
.collect();
assert!(
counted.first().is_some_and(|&i| (14..=16).contains(&i)),
"{name}, water {water}: {counted:?}"
);
assert!(
counted.len() >= 25,
"{name}, water {water}: {} of 36: {counted:?}",
counted.len()
);
}
}
}
/// An elytra: a jump while falling starts a glide, played again exactly.
#[test]
fn a_glide_is_played_again_exactly() {
+183
View File
@@ -8,6 +8,7 @@ use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
sync::{Arc, Mutex},
};
/// RecipeManager.RECIPE_PROPERTY_SETS: each set and the ingredients it takes
@@ -170,3 +171,185 @@ pub fn update_recipes(
}
w.data
}
/// The Horizon channel a server sends its clients the recipes its native mods
/// asked for (`native_datapack_sync`), NeoForge's
/// `OnDatapackSyncEvent.sendRecipes`. Horizon clients only.
pub const CONTENT_CHANNEL: &str = "horizon:recipe_content";
/// `ClientboundCustomPayloadPacket`'s cap on a payload, less room for the
/// envelope.
const CONTENT_MAX: usize = 1_048_576 - 1024;
/// One payload of the recipe content: some recipes as `(id, json)`, and on the
/// last one the recipe types they replace on the client (a type the server has
/// no recipe of still empties the client's).
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ContentPart {
pub recipes: Vec<(String, Value)>,
pub last: Option<Vec<String>>,
}
/// The payloads for `types`' recipes among `sources` (`Recipes::sources`,
/// vanilla's ids bare), ids made whole (`minecraft:<name>`). With no type
/// asked for: nothing, or with `reset` (this server sent recipes before) one
/// last part with no types, which puts its clients back on their own. A
/// recipe too big for a payload of its own is left out.
pub fn encode_content(types: &[String], sources: &[(String, Value)], reset: bool) -> Vec<Vec<u8>> {
// The last part carries the types: they count against its cap too.
let types_len = serde_json::to_string(types).map_or(0, |t| t.len()) + 32;
if types_len > CONTENT_MAX {
log::warn!("Horizon: too many recipe types to send to clients");
return Vec::new();
}
if types.is_empty() && !reset {
return Vec::new();
}
let mut out = Vec::new();
let mut part: Vec<(String, Value)> = Vec::new();
let mut size = 0;
for (name, v) in sources {
if !types.iter().any(|t| v["type"].as_str() == Some(t)) {
continue;
}
let id = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
let len = id.len() + v.to_string().len() + 8;
if len > CONTENT_MAX {
log::warn!("Horizon: recipe {id} is too big to send to clients");
continue;
}
if size + len > CONTENT_MAX {
let recipes = std::mem::take(&mut part);
out.push(
serde_json::to_vec(&ContentPart {
recipes,
last: None,
})
.unwrap_or_default(),
);
size = 0;
}
size += len;
part.push((id, v.clone()));
}
if size + types_len > CONTENT_MAX {
let recipes = std::mem::take(&mut part);
out.push(
serde_json::to_vec(&ContentPart {
recipes,
last: None,
})
.unwrap_or_default(),
);
}
let last = ContentPart {
recipes: part,
last: Some(types.to_vec()),
};
out.push(serde_json::to_vec(&last).unwrap_or_default());
out
}
/// What the server sends its Horizon clients now (`set_content`).
static CONTENT: Mutex<Option<Arc<Vec<Vec<u8>>>>> = Mutex::new(None);
/// The recipe content the server sends from now on, at a join and after a
/// `/reload` (one server per process, as `lan::sync_data`).
pub fn set_content(payloads: Vec<Vec<u8>>) {
if !payloads.is_empty() {
SENT.store(true, std::sync::atomic::Ordering::Relaxed);
}
*CONTENT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(payloads));
}
/// Whether this server sent its clients recipes before: a later load that
/// asks for none must put them back on their own (`encode_content`'s reset).
static SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn sent_before() -> bool {
SENT.load(std::sync::atomic::Ordering::Relaxed)
}
/// The payloads to send a Horizon client; none when no mod asked.
pub fn content() -> Arc<Vec<Vec<u8>>> {
CONTENT
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.unwrap_or_default()
}
#[cfg(test)]
mod content_tests {
use super::*;
use serde_json::json;
#[test]
fn content_keeps_the_asked_types_and_ends_with_them() {
let sources = vec![
("furnace".to_owned(), json!({"type": "minecraft:smelting"})),
(
"stick".to_owned(),
json!({"type": "minecraft:crafting_shaped"}),
),
("mymod:crush".to_owned(), json!({"type": "mymod:crusher"})),
];
assert!(encode_content(&[], &sources, false).is_empty());
// After a sync that sent recipes, asking for none sends an empty last part.
let reset = encode_content(&[], &sources, true);
assert_eq!(reset.len(), 1);
let part: ContentPart = serde_json::from_slice(&reset[0]).unwrap();
assert!(part.recipes.is_empty());
assert_eq!(part.last.as_deref(), Some(&[][..]));
let types = vec![
"minecraft:smelting".to_owned(),
"mymod:crusher".to_owned(),
"x:none".to_owned(),
];
let parts = encode_content(&types, &sources, false);
assert_eq!(parts.len(), 1);
let part: ContentPart = serde_json::from_slice(&parts[0]).unwrap();
let ids: Vec<&str> = part.recipes.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["minecraft:furnace", "mymod:crush"]);
assert_eq!(part.last.as_deref(), Some(types.as_slice()));
}
#[test]
fn content_splits_under_the_payload_cap() {
let big = "x".repeat(300_000);
let sources: Vec<_> = (0..10)
.map(|i| (format!("m:r{i}"), json!({"type": "m:t", "pad": big})))
.collect();
let parts = encode_content(&["m:t".to_owned()], &sources, false);
assert!(parts.len() >= 3);
let mut n = 0;
for (i, p) in parts.iter().enumerate() {
assert!(p.len() <= 1_048_576);
let part: ContentPart = serde_json::from_slice(p).unwrap();
assert_eq!(part.last.is_some(), i + 1 == parts.len());
n += part.recipes.len();
}
assert_eq!(n, 10);
}
#[test]
fn a_long_type_list_keeps_the_last_part_under_the_cap() {
let types: Vec<String> = (0..5000)
.map(|i| format!("mod{i}:type_with_a_long_name"))
.collect();
let big = "x".repeat(1_000_000);
let sources = vec![(
"m:r".to_owned(),
json!({"type": "mod0:type_with_a_long_name", "pad": big}),
)];
let parts = encode_content(&types, &sources, false);
assert_eq!(parts.len(), 2);
assert!(parts.iter().all(|p| p.len() <= 1_048_576));
let last: ContentPart = serde_json::from_slice(&parts[1]).unwrap();
assert!(last.recipes.is_empty());
assert_eq!(last.last.map(|t| t.len()), Some(5000));
}
}
+14 -11
View File
@@ -8,17 +8,18 @@ pub mod arrows;
pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light,
lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen,
packs, par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers,
screen_draw, screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list,
signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit,
clouds, collisions, command_block, containers, crash, datapack, dynamic_light, effects,
environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -102,6 +103,7 @@ pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
@@ -110,6 +112,7 @@ pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
+2 -1
View File
@@ -2,7 +2,7 @@
#![forbid(unsafe_code)]
mod game;
use horizon_client::{crash, logging};
use horizon_client::{crash, logging, ram_limit};
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
@@ -54,6 +54,7 @@ fn main() {
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
+18
View File
@@ -21,6 +21,7 @@ pub mod breeze;
pub mod camel;
pub mod caravan;
pub mod cem;
pub mod cem_anim;
pub mod combat;
pub mod control;
pub mod conversion;
@@ -50,6 +51,7 @@ pub mod illager;
pub mod illusioner;
pub mod item_break;
pub mod keyframes;
pub mod landing;
pub mod leash;
pub mod living;
pub mod llama;
@@ -65,6 +67,7 @@ pub mod piglin;
pub mod polar_bear;
pub mod rabbit;
pub mod raider;
pub mod random_textures;
pub mod ravager;
pub mod render;
pub mod riders;
@@ -1188,6 +1191,8 @@ pub struct Mob {
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
/// The packs' random texture variants it wears (`random_textures::choose`).
pub random_textures: random_textures::Choice,
}
/// A mannequin's `description` and `hide_description` (`Mannequin.updateDescription`):
/// the default `entity.minecraft.mannequin.label`, none at all, or a component.
@@ -1394,6 +1399,16 @@ pub enum MobEvent {
ElderCurse {
at: [f64; 3],
},
/// `Entity.setDeltaMovement`: replace a target's velocity.
SetVelocity {
target: Who,
velocity: [f64; 3],
},
/// `Entity.remove(RemovalReason.KILLED)` of a mob that is no longer alive: it vanishes at once,
/// with no death animation (`ShootTongue.eatEntity`).
RemoveKilled {
target: u32,
},
/// `Entity.push`: add to a target's velocity.
Push {
target: Who,
@@ -2344,6 +2359,8 @@ pub struct MobWorld<'a> {
pub thundering: bool,
pub nether: bool,
pub block_tags: &'a HashMap<String, std::collections::HashSet<String>>,
/// Each block's `fallOn` rule (the player's landing registry).
pub landing: &'a horizon_world::block_landing::BlockLandingRegistry,
pub biomes: &'a crate::biome::Biomes,
/// The `mob_griefing` game rule.
pub mob_griefing: bool,
@@ -2466,6 +2483,7 @@ impl Mob {
slim: false,
cape: None,
profile: None,
random_textures: Default::default(),
attrs: Attrs {
speed: kind.speed,
attack_damage: kind.attack_damage,
+14
View File
@@ -765,6 +765,20 @@ fn effects_from_blocks(
m.damage(1.0, Damage::of(DamageKind::SweetBerryBush));
}
}
// PowderSnowBlock.entityInside: a living entity is stuck
// (and forgets its fall) only while the block at its
// own feet is powder snow.
"minecraft:powder_snow"
if w.block_name(
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
) == "minecraft:powder_snow" =>
{
m.fall_distance = 0.0;
m.stuck_multiplier =
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32));
}
"minecraft:honey_block" => honey_slide(m, p, level_random, events),
_ => continue,
}
+26 -3
View File
@@ -23,6 +23,8 @@ pub struct CemModels {
/// A model layer as a `.jem` reshapes it.
pub struct CemLayer {
pub model: Arc<Part>,
/// The `.jem`'s animations, run after the vanilla animation.
pub animation: Option<super::cem_anim::Animation>,
/// The atlas name of the `.jem`'s texture (`entity/zombie/zombie_green`), drawn instead of
/// the layer's vanilla texture.
pub texture: Option<String>,
@@ -318,6 +320,9 @@ impl CemLayer {
let model = name.replace("_baby", "");
let mut out = vanilla.clone();
let mut count = 0;
let mut ids = HashMap::new();
// Each entry's `this` and `part`, and its animations.
let mut entries = Vec::new();
for p in &jem.parts {
let target = vanilla_part(&model, &p.part);
let target = if find(&out, &target) {
@@ -325,7 +330,7 @@ impl CemLayer {
} else {
p.part.clone()
};
let mut bone = part(&p.bone, &mut count);
let mut bone = part(&p.bone, &mut count, &mut ids);
bone.pose[6..9].fill(p.scale);
let Some(node) = find_mut(&mut out, &target) else {
log::warn!("Horizon: custom entity model {name}: no part {}", p.part);
@@ -334,10 +339,24 @@ impl CemLayer {
if !p.attach {
node.polygons.clear();
}
// A replacing entry stands in for the vanilla part; an attached one is its own.
let this = if p.attach {
bone.name.clone()
} else {
target.clone()
};
entries.push((this, target, p.animations.as_slice()));
node.children.push(bone);
}
let names = super::cem_anim::Names {
model: &out,
vanilla: &|p| vanilla_part(&model, p),
ids: &ids,
};
let animation = super::cem_anim::compile_animation(name, &names, &entries);
Self {
model: Arc::new(out),
animation,
texture: jem.texture.as_deref().and_then(atlas_name),
}
}
@@ -364,9 +383,13 @@ fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
/// A baked bone as a model part. Its name, `cem#<n>`, is unique in the model, so its pose is
/// its own whatever the files call their ids.
fn part(bone: &Bone, count: &mut usize) -> Part {
/// `ids` gets the bone's id, if it has one, under that name.
fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Part {
let name = format!("cem#{count}");
*count += 1;
if let Some(id) = &bone.id {
ids.insert(name.clone(), id.clone());
}
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
Part {
@@ -380,7 +403,7 @@ fn part(bone: &Bone, count: &mut usize) -> Part {
vertices: q.vertices,
})
.collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
children: bone.children.iter().map(|c| part(c, count, ids)).collect(),
}
}
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more