Compare commits

...
Author SHA1 Message Date
levzzz ddb317c771 Merge branch 'pr-5909-new' into mod-api-rework 2026-11-01 20:40:22 +05:00
[email protected]andClaude Opus 5.5 aef97f1e43 Merge upstream/main into staging/unified-hmod
Upstream added --player-dir handling to the startup code in main.rs. With the
library split that code lives in entry.rs, so the change is applied there and
main.rs stays the thin launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:35:47 +01:00
levzzz 3f3bdfa663 Merge branch 'pr-5909' into mod-api-rework 2026-11-01 20:24:21 +05:00
Ludovic Marie 9610099341 Merge pull request #6041 from horizon-rust/perf/lazy-loot-tables
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous memory −7.2 MB on every start, Done and 20-bot MSPT unchanged (5 rounds). pr_ready ✅ (410 tests + 21 content root); clippy -D warnings and fmt clean on the Mac.
2026-11-01 16:24:04 +01:00
levzzzandClaude Opus 5.5 5482b975fb Merge hr/main into the mod API rework (#5446)
main.rs moved to entry.rs on this branch; carry main's --player-dir
log/crash folder handling over to entry.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:18:53 +05:00
[email protected]andClaude Opus 5.5 18a02fe574 Merge native/api-mods (#5446) into staging/unified-hmod
Combined staging branch: the game library split (#5446) and the single mod
file format (#5909) together, as agreed to land in one release. No conflicts.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:03:03 +01:00
Ludovic Marie 486c22db0d Merge pull request #5646 from JustKarkat/test/experience-orb-stuck-case
🤖 Agent: #5601 has merged, so this test-only follow-up leaves draft: main merged (corpus union, every function checked against its source), verify green on 938f032. Thanks @JustKarkat.
2026-11-01 15:57:10 +01:00
Ludovic MarieandClaude Opus 5.5 a6d37d7f68 Merge main into #4947: failed tiles still wait out their retry in main's wanted_tiles queue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:53:58 +01:00
Ludovic Marie f03d1b80a0 Merge pull request #5894 from Teiwazik/shader/block-texture
🤖 Agent: shader API series stacked on one union (each head holds the earlier ones); pr_ready --quick green on the last head 47b5a77 (2574 tests), shader-api tests + clippy on each earlier head; offscreen in-game run with a pack using all four features, no validation error.
2026-11-01 15:52:34 +01:00
Ludovic Marie 0817971f73 Merge pull request #6005 from kacpherek/perf/opaque-terrain-pass
🤖 Agent: Opaque terrain pass without discard: two solid passes on Metal (Apple's tiled GPUs, measured faster on M1 Pro), one pass elsewhere as before (fix pushed here); the far-tile opaque marking is fixed everywhere.
2026-11-01 15:52:27 +01:00
Ludovic Marie f860ccf026 Merge pull request #6012 from Sylviromi/datapack-command-reload
🤖 Agent: /datapack enable and disable reload the server's data as ReloadCommand.reloadPacks does (reload_world_tables, as /reload); four lines.
2026-11-01 15:52:19 +01:00
Ludovic Marie 737a34b70c Merge pull request #6010 from Xmilon/native/mouse-button-events
🤖 Agent: Raw mouse button pre/post hooks (fragments): no-ops without a mod, synthetic clicks skipped; test runs the real dispatch order.
2026-11-01 15:52:15 +01:00
Ludovic Marie 795b56ee09 Merge pull request #6000 from Xmilon/native/compute-fog-color
🤖 Agent: native_compute_fog_color hook (fragment): no-op without a mod, finite 0..=1 RGB only; test runs the real dispatch.
2026-11-01 15:52:11 +01:00
Ludovic Marie f113b76de6 Merge pull request #6006 from xFedeT/chore/port-map-stale
🤖 Agent: Port map only: 27 missing rows point at the code that ports them; skip-release.
2026-11-01 15:52:04 +01:00
Ludovic Marie cb356bf994 Merge pull request #5993 from HrafnaValkyrja/fix/boat-underwater
🤖 Agent: A boat used under water lands at the eye and is refused as in vanilla's BoatItem.use (ClipContext.Fluid.ANY on a full fluid cell); unit test, verify green.
2026-11-01 15:51:50 +01:00
Ludovic Marie 6f1501d309 Merge pull request #4946 from horizon-rust/server-asks/lod-server
🤖 Agent: far terrain 2/3 (the server samples tiles, off by default). #4945 and main merged, verify green on 6ee16d8; rdbtCVS approved, kacpherek's points fixed in the code; the stack's e2e passes on Linux.
2026-11-01 15:51:43 +01:00
Ludovic Marie de2577f9ff Merge pull request #5888 from Joansitoh/fix/5698-hide-break-particles
🤖 Agent: Extras: hide block-break particles, off by default; merged main in (extras.rs beside uiSoundReserve and splitscreen); verify green.
2026-11-01 15:51:35 +01:00
Ludovic Marie c6a691be44 Merge pull request #4945 from horizon-rust/server-asks/lod-wire
🤖 Agent: far terrain 1/3 (wire format, off by default). Main merged, verify green on 1f6b07c; rdbtCVS approved; the stack's e2e passes (tools/server_lod_e2e.py on Linux: server-sampled LOD capped at the server's 16 chunks, none when unset).
2026-11-01 15:51:30 +01:00
Ludovic Marie 7b97de798d Merge pull request #5868 from oskal675-design/fix/gliding-explosion-impulse
🤖 Agent: requeued: the parity failure was the runners' (a dead .horizon-prepare.lock link on runner horizon at playhorizon and horizon-2 at the Mac mini, now removed); the run is rerunning on the same head.
2026-11-01 15:51:25 +01:00
Ludovic Marie 9513756c20 Merge pull request #5944 from HrafnaValkyrja/fix/wide-mobs-suffocate
🤖 Agent: Wide mobs (hoglins…) suffocate in walls with vanilla's isInWall box; reviewed, verify green.
2026-11-01 15:51:20 +01:00
Ludovic Marie f9ccf49446 Merge pull request #5883 from Joansitoh/fix/5567-tnt-passengers
🤖 Agent: Primed TNT keeps its Passengers through save and load as vanilla's Entity.saveWithoutId; reviewed, verify green.
2026-11-01 15:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 67ebb0257e Merge main into #5693: the two uniforms after main's isRightHanded … hasCeiling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:47:07 +01:00
Ludovic Marie c05ef84a56 Merge pull request #5598 from JulesB40/ccr-b42808eb-bs7uli
🤖 Agent: security-reviewed by Ludovic's AI agent (Claude): the secrets file is now encrypted at rest (ChaCha20-Poly1305, key from the computer's machine-id), mode 0600, atomic replace, no token in logs or errors, the owner gate unchanged. 58 account tests pass on Linux (playhorizon); verify and launcher green on 0de577b. Thanks @JulesB40.
2026-11-01 15:37:33 +01:00
Ludovic Marie 5b9ffaaec6 Merge pull request #5992 from Xmilon/native/detached-camera-distance
🤖 Agent: native_camera_distance hook: a no-op fragment hook until a mod fills it, finite 0..=1024 overrides only, collision clipping kept; unit tests cover it.
2026-11-01 15:37:29 +01:00
Ludovic Marie f4d7883389 Merge pull request #5854 from Sylviromi/screen-render-post-prompt
🤖 Agent: Review by Ludovic's AI agent (Claude). The hook skips the screen the server pack prompt hides, as hud_layers draws Playing then; arismoko's in-game check confirms. I merged main; verify green.
2026-11-01 15:37:24 +01:00
Ludovic Marie 3bcd9c3372 Merge pull request #5795 from Detimes/docs-5785-player-projectiles
🤖 Agent: Review by Ludovic's AI agent (Claude). Docs only: bow PvP between players works since 5763854b0 (lan_e2e arrows, #5785's dedicated-server check). I merged main; verify green.
2026-11-01 15:37:19 +01:00
Ludovic Marie bb1a6be3fe Merge pull request #5789 from kacpherek/fix/dragon-every-dimension
🤖 Agent: Review by Ludovic's AI agent (Claude). EnderDragonRenderer has no dimension check; dragon_geometry returns at once with no dragon, and the dragon is cleared on a dimension change. I merged main; verify green.
2026-11-01 15:37:12 +01:00
Ludovic Marie 9d15888d8e Merge pull request #5778 from abit-foggy/fix/lod-anisotropic-streaks
🤖 Agent: Review by Ludovic's AI agent (Claude). Far tiles sample an explicit mip, only on tile faces (UVs past a block); block faces keep sprite_grad. A few ALU ops per far-tile fragment. I merged main; verify green.
2026-11-01 15:37:07 +01:00
Ludovic Marie 97f84d9beb Merge pull request #5984 from NerfPhaselock/perf/translucent-arena
🤖 Agent: Translucent batching 1/3: off by default (only horizon-graphics.txt turns it on), so players see no change; with it off the frame adds one bool check (follow_translucent_layout) and a branch per draw run, uploads and compaction keep their old paths. Measured gains in the body are for the opt-in path.
2026-11-01 15:37:03 +01:00
Ludovic Marie d1a804f466 Merge pull request #5741 from Stink-O/native/mod-screens
🤖 Agent: Review by Ludovic's AI agent (Claude). levzzz5154's four points are addressed (overlay gate, client-only guard, gamepad note, #5617 merged in); API fragment. I merged main (merges with #5731 cleanly); verify green.
2026-11-01 15:36:58 +01:00
Ludovic Marie 57d83bfa07 Merge pull request #5910 from Sylviromi/recipe-filters
🤖 Agent: Data-pack and content-mod recipe removal/replacement by filter, load time only; reviewed, verify green.
2026-11-01 15:36:52 +01:00
Ludovic Marie 6c0bf45fd8 Merge pull request #5945 from HrafnaValkyrja/fix/spellcaster-hand-particles
🤖 Agent: Evoker and illusioner spell hand particles as vanilla's SpellcasterIllager.tick; reviewed, verify green.
2026-11-01 15:36:46 +01:00
Ludovic Marie cdda320c95 Merge pull request #5914 from HrafnaValkyrja/fix/plate-trident-rocket-hook
🤖 Agent: Tridents, rockets and fishing hooks press plates and trip tripwires (entity-inside checks as vanilla); reviewed, verify green.
2026-11-01 15:36:40 +01:00
Ludovic Marie 0d4edd409c Merge pull request #5988 from dill-lk/main
🤖 Agent: parity.py treats a remote OSError as a failed fetch instead of crashing; Python only, tests pass, verify green.
2026-11-01 15:36:36 +01:00
Ludovic MarieandClaude Opus 5.5 887f6e9750 Opaque pass: two solid passes on Metal only
The split saves Apple's tiled GPUs their hidden surface removal; elsewhere
the second pass doubled the solid draw calls (one per section without
multi-draw) and the vertex work for no gain that was measured. Other
backends keep the single fs_solid pipeline, the flat one included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:27:30 +01:00
Ludovic MarieandClaude Opus 5.5 28aa93145d Format the opaque pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:23:51 +01:00
Ludovic Marie 15cc1e4d8e Merge remote-tracking branch 'origin/main' into pk1/6005 2026-11-01 15:22:57 +01:00
Ludovic Marie e6eed3e11f Merge pull request #5982 from TiggerTakkyTTT/fix/server-management-difficulty
🤖 Agent: Management protocol serversettings/difficulty get/set as MinecraftServerSettingsServiceImpl, saved to server.properties and server.toml; the management harness covers it.
2026-11-01 15:22:25 +01:00
Ludovic Marie c58aeb2c52 Merge pull request #5973 from oskal675-design/fix/boat-paddles-on-dismount
🤖 Agent: Boat paddle flags reset to false (AbstractBoat.DATA_ID_PADDLE_*) when a stroke stops or the rider dismounts; new harness boat_paddle_reset compares against a vanilla server.
2026-11-01 15:22:18 +01:00
Ludovic Marie e937f37995 Merge pull request #5972 from HrafnaValkyrja/fix/splash-hits-thrower
🤖 Agent: Thrown potions hit players with vanilla's pick margin and leftOwner rule (Projectile.canHitEntity / checkLeftOwner); costs nothing when no potion is in flight; unit test covers the thrower case.
2026-11-01 15:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 9d56f3371a --player-dir: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:21:59 +01:00
Ludovic Marie 2ac1a8e83b Merge pull request #5958 from doodlyyousuf/fix/5912-painting-place-swing
🤖 Agent: Placing a painting swings the hand as vanilla's HangingEntityItem.useOn (InteractionResult.SUCCESS) does; tests pass, parity runs on the head.
2026-11-01 15:21:56 +01:00
Ludovic Marie 4b2196179a Merge pull request #5659 from some-du6e/optifine/emf-geometry
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on 64d0b1a4e). Thanks @some-du6e.
2026-11-01 15:21:51 +01:00
Ludovic Marie 34cc13febe Merge pull request #5647 from some-du6e/optifine/emf-expr
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on b88a1aad1). Thanks @some-du6e.
2026-11-01 15:21:45 +01:00
[email protected]andCodex 0190d098d6 feat(mods): raw mouse button pre and post events
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:17:44 +01:00
Ludovic Marie 71b1ed56fe Merge pull request #5622 from some-du6e/optifine/emf-jem
🤖 Agent: OptiFine series step verified (licence gate, gating to packs that ship the files, per-mob cost measured, verify green on d454a1fcb). Thanks @some-du6e.
2026-11-01 15:15:47 +01:00
Ludovic Marie ffb93f3791 Merge pull request #5986 from gigamans442/fix/custom-uniform-terminator
🤖 Agent: Shader packs: a custom variable/uniform expression may end with one ';' (trimmed), extra statements and empty expressions still rejected; unit tests.
2026-11-01 15:15:42 +01:00
Ludovic Marie 8d724b6150 Merge pull request #5980 from gigamans442/fix/shader-image-option-macros
🤖 Agent: Shader packs: custom image dimensions expand pack option macros (default and chosen), the option table is still built once; literals unchanged, unknown names still rejected; unit tests.
2026-11-01 15:15:38 +01:00
Ludovic Marie a61d6c8a5f Merge pull request #5974 from gigamans442/fix/shader-option-declared-default
🤖 Agent: Shader options: a declared default missing from its authored range stays selectable (inserted first), empty ranges still create no option; unit tests.
2026-11-01 15:15:33 +01:00
Ludovic Marie 7785e9c909 Merge pull request #5968 from gigamans442/fix/compute-dispatch-splats
🤖 Agent: Shader packs: a one-literal vector constant (ivec3(1)) in workGroups/workGroupsRender fills every component (GLSL 4.60 §5.4.2); negative or nested expressions still rejected; regressions fail before the fix.
2026-11-01 15:15:29 +01:00
Ludovic Marie de41830167 Merge pull request #5966 from TobiasLogic/shaderpacks/dimension-uniforms
🤖 Agent: Shader packs: isRightHanded, is_on_ground and the dimension's bedrockLevel/heightLimit/logicalHeightLimit/hasSkylight/hasCeiling get real values (black-box probe of the uniform values, no source read); set per frame only while a pack is on.
2026-11-01 15:15:24 +01:00
Ludovic Marie 75b1798f1d Merge pull request #5964 from TobiasLogic/shaderpacks/fog-frag-coord
🤖 Agent: Shader packs: gl_FogFragCoord becomes a float varying (hz_FogFragCoord) like gl_FrontColor; translator unit test; GLSL 1.20 spec, no other project's code. horizon-shaders only, verify green.
2026-11-01 15:15:19 +01:00
Ludovic Marie 8b11051f3b Merge pull request #5895 from Vaspyyy/feat/native-mob-submit
🤖 Agent: Client hook native_mob_submit as an API fragment; I fixed arismoko's two review points (walk state from mobs::anim::state: no walk while riding/dead, babies x3; hook asked before the sulfur cube's block). Verify green on 62e9cf2ba; the hook is a no-op stub without mods.
2026-11-01 15:15:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 c80a4f57d5 Port map: 27 rows that said missing are in the code
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 15:15:10 +01:00
levzzz 24ef0d341d Merge pull request #5976 from Xmilon/native/viewport-fov
feat(mods): computed world field-of-view hook
2026-11-01 19:11:28 +05:00
[email protected]andCodex e0d42f6719 feat(mods): adjust computed world fog color
Co-Authored-By: Codex <[email protected]>
2026-11-01 15:10:03 +01:00
Ludovic Marie 4aa6854a88 Merge pull request #5742 from some-du6e/fix/lod-near-detail
🤖 Agent: Reviewed lod.rs (seam stand-in rule in select and evaluate, coarse backdrop in the wanted queue, load order only). fmt/clippy/lod tests green in verify on this head. Thanks @some-du6e.
2026-11-01 15:08:52 +01:00
Ludovic Marie 081172f935 Merge pull request #5902 from Sylviromi/data-reloaded
🤖 Agent: Requeued after merging main (conflict beside native_data_pack_files, both hooks kept, 3 files +24 vs main). Server hook native_server_data_loaded(reload) as an API fragment; empty stub, no cost.
2026-11-01 15:08:46 +01:00
Ludovic MarieandClaude Opus 5.5 dc105082c2 Merge main into fix/5698-hide-break-particles (beside uiSoundReserve)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:07:30 +01:00
Ludovic Marie 4b87c54096 Merge pull request #5653 from Joansitoh/fix-3333-mojang-republish
CI: daily check for a republished Mojang version JSON
2026-11-01 15:07:10 +01:00
Ludovic MarieandClaude Opus 5.5 a498176bd7 Merge main into #5741
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:06:49 +01:00
HrafnaValkyrjaandClaude Opus 5.5 dd3e33c0ec Boats can't be placed from underwater
A fluid hit always snapped to the top of the water cell, so a boat landed
above a submerged player. In a full water cell the hit now stays on the
ray, as vanilla's VoxelShape.clip has it, so the boat collides with the
player and nothing is placed.

Fixes #5967

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

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

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

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

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

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

Fixes #5876

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:46:49 -05:00
Ludovic MarieandClaude Opus 5.5 40704247c7 Merge main into #5685: uiSoundReserve beside main's splitscreen extra
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:46:28 +01:00
Ludovic MarieandClaude Opus 5.5 865f808792 Merge main into #5854
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:45:26 +01:00
oskal675-designandCodex GPT-6.1 326532f859 Reset boat paddle metadata when rowing stops
Send the default false values when paddle entries disappear from tracked metadata, including dismounting mid-stroke. Compare 18 transitions with actual vanilla servers.

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

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

Review points from arismoko's review.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:41:02 +01:00
Ludovic Marie aeadf92f84 Merge main into #5598 2026-11-01 14:39:50 +01:00
Ludovic Marie d02dd55ae3 Merge pull request #5946 from hello23113/fix/windows-cpu-name
🤖 Agent: Windows F3/crash-report CPU name from the registry's ProcessorNameString with the old fallback; read once (OnceLock) so no per-frame cost; the launcher starts the game with CREATE_NO_WINDOW so reg.exe opens no console. Parser unit-tested.
2026-11-01 14:39:20 +01:00
Ludovic Marie 805d85cec0 Merge pull request #5877 from Joansitoh/fix/5673-mcp-protocol-version
🤖 Agent: MCP initialize now negotiates the protocol version (echo only a supported one), same 12-line helper in horizon-ctl, horizon-server and the launcher, with unit tests; verify and launcher CI green.
2026-11-01 14:39:16 +01:00
Ludovic Marie e9351841b5 Merge pull request #5880 from levzzz5154/docs/content-mods-refresh
🤖 Agent: Docs only (mods.md, modpacks.md, template comment, manifest doc comment); checked the facts against the content loader (sounds key names a vanilla block, menu/entity_type kinds, mismatch refusal). The failing 'check' job is horizon-mod-registry's lock on main, unrelated.
2026-11-01 14:39:12 +01:00
TobiasLogicandClaude Opus 5.5 b80d1a0851 Shader packs: isRightHanded, is_on_ground and the dimension's uniforms
Packs read these Iris uniforms as zero: isRightHanded (the Main Hand option), is_on_ground, and
the dimension type's bedrockLevel, heightLimit, logicalHeightLimit, hasSkylight and hasCeiling
(from the level's shape; hasCeiling in the Nether). A pack reading bedrockLevel 0 and heightLimit
0 in the overworld puts its fog or its sky's horizon at the wrong height.

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

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

Part of #5147

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:33:20 -05:00
Ludovic MarieandClaude Opus 5.5 aaf25ac9a9 Merge main into #5693: the pack inputs keep main's mod params beside the two new uniforms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:28 +01:00
Ludovic MarieandClaude Opus 5.5 5c785a252c Format the pass timing calls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:30 +01:00
Ludovic Marie 7ec69de302 Merge remote-tracking branch 'origin/main' into pk1/5887 2026-11-01 14:27:16 +01:00
Ludovic MarieandClaude Opus 5.5 9ec5db50d1 Arrows keep the shooter's horizontal motion on the ground, as vanilla
Projectile.shootFromRotation adds the shooter's known movement and drops
only its y on the ground, and sets no rotation after it: an arrow shot
while strafing drifts with the player in vanilla too.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:26:39 +01:00
HrafnaValkyrjaandClaude Opus 5.5 394725e84e Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 08:26:11 -05:00
Ludovic Marie 374b50d48f Merge pull request #5890 from Teiwazik/fix/mesh-pool-lights
🤖 Agent: verified at e241409 on the Mac mini: fmt, clippy -D warnings (horizon-client-render), mesh_pool tests pass; the new assert fails without the fix. Thanks @Teiwazik.
2026-11-01 14:24:50 +01:00
Kacpherek aa4622805d Draw opaque terrain through a shader that never discards
The solid terrain shader could discard every pixel (the fade dither and
the alpha cutout), and one discard anywhere in a pipeline turns off the
hidden surface removal of tiled GPUs (Apple's) for everything it draws.
The world pass now draws each solid list twice: first the quads over
opaque texels that are not fading, through fs_opaque, which has no
discard, then the rest through fs_solid. Each vertex shader decides its
pass before any other work; a quad of the other pass lies outside the
clip volume. Shader packs and native materials keep the one pipeline.

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

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

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

Fixes #5886

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

Fixes #5872

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

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

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

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

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

Fixes #5860.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:37:34 +01:00
Ludovic Marie b8b5020fbb Merge pull request #5679 from TiggerTakkyTTT/fix/end-portal-entities
🤖 Agent: main moved after the queue (#5875 and #5755 merged), so I merged main in again (clean, d635bc57f). `cargo check -p horizon-client --lib --bins --tests` passes. Requeued.
2026-11-01 13:36:36 +01:00
[email protected]andClaude Opus 5.5 df6e0f0547 Mods: keep publishing .hnmod, write the mod file format down
Review on #5909: native versions published as .hmod would reach
launchers that drop their content or refuse them. The registry keeps
.hnmod until launchers and the game read native .hmods.

docs/api/mod-files.md holds every rule of a .hmod/.hnmod in one
place: zip and path rules, top-level entries, the horizon-native.json
schema, what mixins/<id>/ may hold and the size limits.

The registry's Cargo.lock catches up with horizon-mods' png
dependency so its --locked check passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:35:43 +01:00
Ludovic Marie ff6cad6206 Merge pull request #5903 from repositorykreml1n/fix/attack-through-gaps-5772
🤖 Agent: proven on a union with main c1f4a7c19 (+ #5875, #5755, #5679): `pr_ready.py --quick` passed (fmt, clippy -D warnings, horizon-client tests, doc tests). I added a lan_e2e check, `attack_over_slab` (97c18d712): a NoAI zombie stands behind a bottom slab, the player aims at its head over the slab and left-clicks. It passes for the host (20 → 19.06) and for the guest (19 → 18). Queued for merge. Thanks @repositorykreml1n!
2026-11-01 13:35:28 +01:00
Ludovic Marie d635bc57f9 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 13:34:42 +01:00
Ludovic Marie 7bca933bb5 Merge pull request #5755 from navinor/fix/unbreakable-durability-bar
🤖 Agent: verified by Ludovic's AI agent (Claude). ItemStack.isBarVisible needs isDamageableItem, which an unbreakable stack isn't, and the unbreakable unit component now reaches clients. pr_ready --quick passed on main c1f4a7c19 with this PR, #5875, #5903 and #5679 merged in (fmt, clippy, horizon-client tests incl. the new sync test).
2026-11-01 13:30:37 +01:00
Ludovic Marie d0afa1edc1 Merge pull request #5875 from TiggerTakkyTTT/fix/creative-shulker-drops
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff against ShulkerBoxBlock.playerWillDestroy and Block.popResource (offsets drawn before the block_drops check, as vanilla). pr_ready --quick passed on main c1f4a7c19 with this PR, #5903, #5755 and #5679 merged in (fmt, clippy -D warnings, horizon-client tests, Python tests). CI parity covers the new command_exec cases.
2026-11-01 13:30:33 +01:00
Ludovic Marie 5bbecdc497 Merge pull request #5915 from horizon-rust/perf/block-state-properties
🤖 Agent: playhorizon 4 CPUs: idle RSS −17 MB with THP off (241.7 → 224.7, every start) and −18 MB median with THP on (4/4), live heap −13.6 MB, Done 2.06 → 1.955 s (4/4); 20-bot MSPT unchanged (3 rounds). pr_ready tests 2066 + content roots pass; merge with main checks; fmt/clippy/Python clean on the Mac.
2026-11-01 13:23:50 +01:00
Ludovic MarieandClaude Opus 5.5 98e49e36c8 lan: make room in the test with a log writer set
The deadlock fixed in the previous commit only shows with a log writer;
the test hangs without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:22:51 +01:00
Ludovic MarieandClaude Opus 5.5 49e91c58c2 Merge #5875 into #5679: the harness's break and falling ops side by side
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:34 +01:00
Ludovic MarieandClaude Opus 5.5 675fba85db Merge main into #5679; a native-spawned falling block starts with no portal state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:11 +01:00
Ludovic MarieandClaude Opus 5.5 97c18d7127 lan_e2e attack_over_slab: a hit aimed at a zombie's head over a bottom slab lands (#5772)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:21:05 +01:00
Ludovic Marie b1d9448018 Merge remote-tracking branch 'origin/main' into g/5679 2026-11-01 13:21:05 +01:00
HrafnaValkyrjaandClaude Opus 5.5 055ce500a0 Tridents, rockets and fishing hooks press plates and trip tripwires
They live in their own lists, so a plate's entity count never saw them.
They now count, as vanilla's getEntitiesOfClass does. A rocket boosting a
player and a hook on an entity count but skip entityInside, whose step
their ticks skip too. A guest's hook ends when the guest changes level, so
it can't act on the blocks of the level it arrives in.

Fixes #5896

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 07:14:27 -05:00
[email protected]andClaude Opus 5.5 f2b508c4f2 test(client): read the loot hook source independent of line endings
loot_table_load_stays_hookable searched for "\n}\n", which a CRLF checkout
(core.autocrlf=true on Windows) never contains. Normalize as the other
source-reading test in this file does.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:06:19 +01:00
[email protected]andClaude Opus 5.5 02122e2532 Mods: one .hmod for content and native code (WIP, #5865)
A .hmod's contents decide what it is: content only, native code
(horizon-native.json + mixins/<id>/), or both in one file. Native
bundle reading skips a .hmod's content entries, checks both
manifests name one mod, and refuses mod.wasm with a clear error.
The registry publishes native-mod versions as .hmod and still
accepts .hnmod; .hnmod keeps loading unchanged.

Launcher install, game content loading of native .hmods and the
GitHub install path are not done yet.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:24:13 +01:00
Ludovic MarieandClaude Opus 5.5 d44fb4367a Block states: shared property strings are the process's, not counted
Cloning a registry state bumped one Arc per name and value; world
generation threads clone the same states, and 20 bots cost 0.25 ms of
streaming. Shared strings are now interned once for the process (the
registries' names and values, never a player's) and copied as pointers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:22:01 +01:00
[email protected]andCodex 452910ffce fix(parity): keep saved probe selections compatible with the library split
Co-Authored-By: Codex <[email protected]>
2026-11-01 12:17:53 +01:00
JoansitohandClaude Sonnet 5.5 d3fe2797d9 docs: list hideBlockParticles in the extras intro
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:10:10 +01:00
JoansitohandClaude Sonnet 5.5 645326f21e Primed TNT carries its Passengers
/summon minecraft:tnt {Passengers:[...]} dropped them. Load them as riders
(EntityType.loadEntityRecursive) and seat them on the TNT's top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:09:03 +01:00
Kacpherek ad4877f432 List gpu-passes in the horizon-ctl reference 2026-11-01 12:07:52 +01:00
TeiwazikandClaude Opus 5.5 e241409e65 Clear a recycled mesh's light sources
SectionMesh::clear kept `lights`, so a mesh taken from the pool still listed the sources of the
section it last held: a broken torch stayed in the colour grid, and sources piled up elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 12:06:42 +01:00
[email protected]andCodex 42a5981e94 refactor(client): isolate gameplay from the reusable client and probes
Keep native hook targets and source paths stable. Run gameplay tests in the gameplay library, and build ctl/probes from horizon-client-lib without gameplay. Update build/test selection and the fixed mixin-tool pin.

Co-Authored-By: Codex <[email protected]>
2026-11-01 12:05:47 +01:00
JoansitohandClaude Sonnet 5.5 20c472f008 mcp: derive launcher PROTOCOL from SUPPORTED, tidy tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:04:20 +01:00
VaspyyyandClaude Opus 5.5 87fb637889 Advancement toast: draw the task/goal label yellow again (#5850)
The HUD batch takes colours packed as hud::rgba (red in the low byte);
0xFFFF_FF00 there is cyan. AdvancementToast draws the label -256 (yellow),
or -30465 (pink) for a challenge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:00:56 +01:00
Stink-O 55f58be289 Merge #5617's approved head into #5741 2026-11-01 10:58:58 +00:00
Stink-OandClaude Opus 5.5 13327b6e0a refactor(mods): the mod screen's Game field after native_net
So it no longer sits on the lines #5731 adds its field to: the two merge in either order without a conflict.

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

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

Fixes #5821

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 10:51:32 +00:00
[email protected]andCodex 2c78a5f1b0 Merge upstream/main before revising the client library layout
Co-Authored-By: Codex <[email protected]>
2026-11-01 11:50:56 +01:00
Jinuk Chanthusa 39c90ba242 Fix parity remote startup failures
Fix parity remote startup failures
2026-11-01 16:17:49 +05:30
JoansitohandClaude Sonnet 5.5 5cd320b24c Extras: hideBlockParticles hides block-break particles
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:47:25 +01:00
JoansitohandClaude Sonnet 5.5 0f3ee35c21 Open the on-screen keyboard in pointer menu mode
Part of #5642.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:45:43 +01:00
JoansitohandClaude Sonnet 5.5 911aebaaf2 mcp: answer initialize with a protocol version we support (part of #5673)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:19 +01:00
JoansitohandClaude Sonnet 5.5 d6c1bf1791 Recover bitmap font glyphs when the dynamic atlas region fills up
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 11:41:07 +01:00
Lostpold88andAntigravity 6548234ac5 Bow arrows no longer drift when strafing and remote arrows update flight pitch
When charging a bow while strafing on the ground, arrows drifted sideways
because player ground movement was added to the projectile velocity and
rotation was not aligned to the resultant vector. In addition, client-side
remote arrows did not update their pitch and yaw during flight, causing
them to fly horizontally like static lasers without following their
parabolic trajectory.

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

Fixes #5855.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:38:04 +00:00
levzzzandClaude Opus 5.5 24f539c633 Content mod docs: every kind it declares, multiplayer, the sounds key, sections in place
Closes #5852.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRKvdg3SMVP1onGqS3
2026-11-01 10:32:50 +00:00
Kacpherek c627a02c56 Pass the timing option on without re-borrowing it 2026-11-01 11:32:38 +01:00
Ludovic MarieandClaude Opus 5.5 b6709fee4f Block states: facts.json reads properties straight into shared strings
The registry's states were read into a BTreeMap of owned strings, then
shared: two allocations and a hash per string, 50 ms of the start. The
facts.json decoder now shares each name and value as it reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:21 +01:00
Kacpherek 9025a1ac46 Show Horizon's own GPU pass times in F3 and the control socket
The renderer's own pass times were not reachable from the client. The F3
overlay now shows them under their own heading, next to the shader pack
group. A new gpu-passes control command times them without opening F3,
and the stats and bench summaries report them as gpu_passes.
2026-11-01 11:30:37 +01:00
Kacpherek 3e9805e295 Time Horizon's own render passes on the GPU
The per-pass GPU timer only covered shader-pack passes, so the F3
overlay could not say which of Horizon's own passes cost the frame.
Give the same timer to the frame's own passes (world, occlusion test,
OIT, HUD, post, HDR, hand, outline, menu blur, native shadow) and add
compute_writes for compute passes.

Passes that already carry timestamps (shader packs, the frame timer),
screenshots, one-off clears and test code are left as they were. The
timer begins at the top of the frame so that passes recorded before
the first draw, such as sprite animation, are timed too.
2026-11-01 11:30:37 +01:00
Stink-O 713798e681 Merge main into #5741 (after #5616) 2026-11-01 10:30:34 +00:00
Stink-O ad13187cf7 Merge #5617's current head into #5741
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-01 10:30:34 +00:00
SylviromiandClaude Opus 5.5 4a02c7d5b9 Native mods: a contributed data pack file that isn't JSON is skipped alone
One bad file from a mod would fail its whole table and drop the world's
pack content with it; it is now skipped with a warning. The hook's docs
list every kind the tables read, trade sets, villager trades and dialogs
included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:26:24 +03:00
levzzzandClaude Opus 5.5 aa21d36fff Launcher: translate the native builds' In use and section labels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:16:27 +05:00
levzzz a75f58462f Merge remote-tracking branch 'hr/main' into pr-5783 2026-11-01 15:10:25 +05:00
Stink-OandClaude Opus 5.5 66ac20304e refactor(extras): the UI sound reserve's match arms on lines of their own
So #5675 (splitscreen), which edits the same two arms, merges with this in either order; only the one-line
serialize format string still meets it (keep both keys).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:14 +01:00
[email protected]andCodex a303d7d105 Merge upstream/main into the client library split
Retain upstream block_contact and the split entry/game exports with test isolation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 10:15:44 +01:00
dill-lkandCopilot d34380935d Fix parity remote startup failures
Co-authored-by: Copilot <[email protected]>
2026-11-01 09:05:53 +00:00
Detimes 8187bdafaf Native mods: a player respawning (native_player_respawned) 2026-11-01 09:04:22 +00:00
SylviromiandClaude Opus 5.5 791ccd18da Native mods: screen_render_post skips the screen the server pack prompt hides
While PackConfirmScreen is open the frame draws no screen under it, but
the render hook still reported the hidden one, so a mod drew over a
screen nobody saw. It now skips that frame, as the input hooks already
do through screen_over_playing.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:02 +00:00
Stink-O 047b17f57b Merge main into #5686 2026-11-01 08:00:19 +00:00
Stink-O dd658dc0b3 Merge main into #5685 2026-11-01 08:00:18 +00:00
Stink-O 670584316e Merge main into #5741 2026-11-01 08:00:13 +00:00
Kacpherek ba01d1e9f7 Draw the ender dragon in every dimension, not only the End
The dragon was drawn only while the player stood in the End, so a
dragon summoned in the Overworld (/summon ender_dragon, which the
server sends to the client as an AddEntity) was heard, flapping and
growling, but never shown. Vanilla's EnderDragonRenderer draws an
EnderDragon in any dimension like any other entity; the gate was
added with the fight, when the dragon only ever existed in the End.
The dragon fireballs share the same block and are now drawn in every
dimension too.
2026-11-01 08:38:50 +01:00
Detimes d172784bdd docs: a player's arrow hurts other players on a served world (#5785)
The server doc still listed bow PvP between guests as missing; it works since 5763854b0 (lan_e2e 'arrows'), and a dedicated server run with two protocol clients agrees.
2026-11-01 07:33:48 +00:00
Stink-O 07901bae47 Merge main into #5741 (both sides' hook tests kept) 2026-11-01 07:26:10 +00:00
Ludovic Marie 58ffd7ed33 Merge remote-tracking branch 'origin/main' into g/5595
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 08:23:44 +01:00
abit-foggyandCodebuff 80a8c274d1 terrain: sample distant tiles at an explicit mip so anisotropic filtering stops streaking
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-31 20:55:02 -10:00
Aaron Wang e3fcbf1b3c Keep unbreakable when syncing an inventory stack
The integrated server retained minecraft:unbreakable, but network_stack
never emitted its unit component. The client therefore still drew a bar
even with the rendering guard in the previous commit. Send UNBREAKABLE
as Component::Unit; the client decoder already preserves unit components.

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

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:16:43 -05:00
Stink-OandClaude Opus 5.5 f641477ff6 feat(mods): native mods can open custom menus
`native_mod_screen_open(mod_id)` opens a full-screen menu of a client mod's own over the world (`Screen::Mod`, its
`NativeScreenView.id` `mod`), `native_mod_screen_close` closes it, `native_mod_screen()` says whose is open, and Escape
closes it as any screen. It draws nothing itself: the screen hooks fire over it as over any other, so the mod draws it
in `native_screen_render_post` and takes its clicks, scroll, keys and typing from the mouse, scroll, key and
character hooks (#5543). Two drawing helpers beside `native_fill` for that: `native_quad` (a wedge or a slanted bar)
and `native_item_model` (an icon drawn with another `item_model`).

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

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

Fixes #5721

Written with Claude Opus 5.5 in OpenCode.
2026-11-01 01:39:49 -04:00
some-du6eandClaude Opus 5.5 64d0b1a4e5 OptiFine CEM: refuse oversized pack files before reading them, log what a model left out
Follows the .jem reader's review changes: files over the model's byte
budget are refused by size, and the reader's warnings reach the log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:01:03 -06:00
some-du6e d3980fec3a Merge optifine/emf-expr into optifine/emf-geometry 2026-10-31 22:59:54 -06:00
some-du6e b88a1aad19 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:57:10 -06:00
some-du6e ddfb64045f Merge the third review round into optifine/emf-expr 2026-10-31 22:57:10 -06:00
some-du6eandClaude Opus 5.5 d454a1fcbd OptiFine CEM: the work budget charges what is walked and copied
- Strings read or copied out of the files (part names, ids, animation
  targets and expressions, axes, numbers written as text) cost one per
  started 64 bytes, each time; animations pay theirs before the copy.
- A submodel costs its keys, a part the layers its keys go through.
- by_id no longer copies an id it already has.
- Tests: shared .jpm with a 1 MB expression and with 200k submodel keys,
  the .jem's bytes in the byte budget, non-object animation items, junk
  boxes that only exhaust when keys are charged.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:30 -06:00
some-du6eandClaude Opus 5.5 a0144420a9 OptiFine CEM: third review round of the expression compiler
- pow follows Java's Math.pow: a NaN exponent is NaN, and 1 or -1 to an
  infinite exponent is NaN; x to 0 stays 1, NaN included
- Test clamp with a NaN upper bound
- The too-long test checks the error lands inside the expression instead
  of pinning an offset that depends on when nodes are counted
- Boundary test: a 512-term sum (1023 nodes) compiles and runs, 513 terms
  are too long

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:49:34 -06:00
some-du6e 344faf4ee9 Merge optifine/emf-jem into optifine/emf-expr 2026-10-31 22:43:49 -06:00
some-du6eandClaude Opus 5.5 3a16789202 OptiFine CEM: fourth review round of the .jem reader
- An entry the budget cuts down to no quads is left out, not drawn empty
- Animations are all or nothing per entry, checked and charged before baking
- A box costs max(faces, keys, 1); non-object items of boxes, submodels and
  animations cost 1, and so do non-scalar animation values
- MAX_BYTES (now 4 MiB, pub) bounds the .jem and all its .jpm files together;
  a cached .jpm counts once
- Boxes whose corners or UVs overflow to non-finite numbers are skipped
- baseId dedup compares only against the entry's own .jpm layer
- Tests: a real lone-surrogate escape case, valid oversized JSON for the byte
  limit, the shared byte budget, repeated keys, non-finite boxes, 1 unit left

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:27:40 -06:00
some-du6eandClaude Opus 5.5 a52700ebae OptiFine CEM: third review round of the .jem reader
- Past the node budget, later parts are left out instead of replacing their vanilla part
  with an empty bone.
- Budget: every box costs at least one, the last box keeps the faces that fit, and the
  "left out" warning fires only when something was cut (Bake { left, cut, unread }).
- Animation pairs count against the same budget.
- Layers carry a key index and a base's layers already present are not added twice.
- Per-part textures and sprites are noted in submodels too.
- JSON: a high surrogate before a non-low escape gives U+FFFD and the escape is read on its
  own; one shared `get`.
- Tests for byte limits, a missing .jpm named twice, own keys over a .jpm's (also in a
  base), the budget's edges, surrogates and comment marks in strings.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:15:01 -06:00
some-du6eandClaude Opus 5.5 af00d7af2b CLAUDE.md: list horizon-optifine among the crates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:09:23 -06:00
some-du6eandClaude Opus 5.5 9469537b14 OptiFine CEM: read .jem files the way OptiFine does, and keep packs inside the root
Review fixes for the .jem reader:
- a small JSON reader keeps keys in file order (animations read what
  earlier ones wrote) and accepts what Gson does: a BOM, comments,
  trailing commas, single quotes, bare keys
- an entry's own .jpm wins over what it inherits through baseId; each
  .jpm is read once
- .jpm paths go through paths::resolve, which refuses ones that leave
  the pack; a missing or broken .jpm, or a baseId naming nothing, leaves
  that entry out with a warning instead of the whole model
- rotations through an odd number of inverted axes turn the right way
- numbers must be finite; attach and animation values may be strings or
  numbers; a file builds at most 1024 entries and 65536 faces
- shadowSize, per-bone textures and attachments are no longer parsed
  until something draws them
- CLAUDE.md lists the crate

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:08:39 -06:00
some-du6e d8c627e75e Merge main into optifine/emf-jem 2026-10-31 22:05:18 -06:00
calclbandDeepSeek Harness 0eb8fc511b name the TNT igniter in the blast's death message
Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 23:03:28 -05:00
TobiasLogicandClaude Opus 5.5 34a47de0bf Shader packs: a pack's vignette=false hides the game's vignette
shaders.properties' `vignette` key (on unless `false`) says whether the game draws its vignette
over the pack's image; most packs turn it off (BSL, Complementary, Bliss, Bloop, FastPBR,
Hysteria...) because they draw their own. Horizon ignored the key, so with the Vignette option on
those packs got both. The game's vignette now draws only when the option is on and the loaded
pack doesn't turn it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:52:27 +07:00
TobiasLogicandClaude Opus 5.5 c7782f2656 Merge main into #5701 (#5371 changed translate/rewrite.rs and mod.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:50:06 +07:00
asownerytandClaude Opus 5.5 10469d5c99 Endermen riding a boat no longer teleport in a loop
Enderman.teleport(x, y, z) fails for a passenger, repeatedlyTryToTeleport
returns before drawing, and EndermanLookForPlayerGoal.tick skips its
teleports while the enderman rides something. Horizon had none of these
checks, so an enderman in a boat "teleported" every tick (sound and
portal particles) and was snapped back to its seat, which also made its
held block jitter. Fixes #4865.

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

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

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

Fixes #5577

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

Part of #3172.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:55:15 +00:00
JustKarkat eb837f2edf Fix: BlockStateId is Copy, drop the pane clone
CI clippy (--all-targets) flags clone on the Copy type; local clippy ran
lib-only and missed the test profile.
2026-10-31 21:40:33 -05:00
martipopsandOpenAI Codex 9d3c50dd6c Keep the guest profile action distinct from the enable page
Give the pause action its own translated Splitscreen Settings label. Check the transient test override through the visible saved switch rather than waiting for an untouched settings file to be written.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 22:36:31 -04:00
d9350b549e Splitscreen: enable joining and choose local profiles
Rebuild the profiles and saved switch increment on current main after the compositor, controller, windows and profile-page model landed. Preserve pause dialogs and keyboard navigation, keep test overrides transient, and validate profile folder names.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:29:57 -04:00
JustKarkat a23b218ae0 Test: phantom pane-bite parity for #4928
Vanilla 26.3's PhantomSweepAttackGoal.tick bites on a plain box check
(getBoundingBox().inflate(0.2).intersects(target)) with no line-of-sight
or block test, and its targeting gate (TargetingConditions' eye-ray) is
what keeps a pane-hidden player safe. Horizon ports both, so:

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

Two parity cases pin both sides of that behaviour, mirroring the live
geometry (player and phantom each pressed against their face of the
pane, 0.125 apart, bite reach 0.2). Refs #4928.
2026-10-31 21:24:17 -05:00
some-du6eandClaude Opus 5.5 91b523cc48 OptiFine CEM: packs' .jem models reshape mobs
Each optifine/cem (or EMF's emf/cem) .jem replaces or joins the parts of
the model layer it names, with OptiFine's part names mapped to the game's
and baby models falling back to the adult's. Built when the packs load
(and on every pack reload, on the reload worker); a mob's draw only swaps
the layer's part tree and, when the .jem names one under textures/, its
texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:58:33 -06:00
Ludovic Marie e0af360ef4 Merge main into #5595's branch
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 02:53:00 +01:00
JoansitohandClaude Sonnet 5.5 4647ce7af7 CI: daily check for a republished Mojang version JSON (#3333)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 02:47:32 +01:00
some-du6eandClaude Opus 5.5 02a522b868 OptiFine CEM: compile animation expressions once, evaluate them without allocating
The CEM expression language from OptiFine's cem_animation.txt with EMF's
documented additions (keyframe, keyframeloop, easings, splines, wrapdeg,
degdiff, catch, randomb, global constants e and nan), compiled to a flat
stack program with constants folded; if, && and || only evaluate the
branch they take. Variables are caller-resolved slots. The example
cem_expr_bench times an animation pack's frame of 20 expressions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:36:18 -06:00
Claude 82dee152fb Launcher: keep counting a secret another process renewed in the file
Review fix for the secrets file (#5095): when the keychain took a
secret but another process had written a newer copy to the file
meanwhile, the newer copy rightly stayed, yet the run stopped counting
it as seen in the file. Once that run stopped asking the keychain and
another process handed the copy over, the run couldn't read it.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:32:00 +00:00
some-du6eandClaude Opus 5.5 63eb8a3b8c Fill far terrain to the horizon with a coarse backdrop while it loads
Main samples only the planned tiles, nearest first: the seam with the
near chunks is fine at once, but the far terrain ends in a hard edge
until the last of ~3000 tiles (Full) is in, minutes on a slow machine.

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

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

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

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

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

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:27:16 -05:00
Claude a307a24a63 Launcher: count the secrets file's keys before the start's hand-over
Review fix for the secrets file (#5095): a start counted what its failed
hand-over left in the file only afterwards, so a hand-over by another
process in between left it reporting the keychain away for those
accounts. The start now counts the file's keys first, and drops each
one it hands over itself.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:25:42 +00:00
Ludovic MarieandClaude Opus 5.5 985647d587 Merge main into #5595's branch: lang files take main's, plus Built on {base}
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:18:50 +01:00
Claude f9fe596cba Launcher: a run started on an old secrets file finds it after a hand-over
Review fix for the secrets file (#5095): a launcher that started while
the keychain was away, on a file a previous run left, asked the
keychain again only for secrets it had written itself. Once another
process handed the file over, it reported the keychain away for
accounts the keychain now held.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 01:15:21 +00:00
Claude f795e86510 Launcher: serialize keychain writes so a hand-over can't undo a sign-out
Review fixes for the secrets file (#5095):

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:00:14 -06:00
Claude 82ea5d145f Launcher: never hold the secrets file's lock while the keychain prompts
Review fix for the secrets file (#5095): Layered asked the keychain
while holding account-secrets.lock, so an unlock prompt held up every
other launcher and CLI process.

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #5326

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-11-01 00:04:41 +00:00
Claude e878abf1fd Launcher: keep Microsoft sign-ins in a file where no keychain answers
Many Linux systems run no Secret Service (GNOME Keyring, KWallet), and
the launcher then kept sign-ins in memory only: players signed in again
at every start (#5095).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DG1mYGWXNxQzmhMnjB36p1
2026-10-31 23:58:12 +00:00
[email protected]andGPT-6 cd85269d59 docs: correct parity selection costs after the client split
Document refreshed probe dep-info, conservative selection growth, and existing reachability narrowing without promising the old harness counts.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:31:27 +01:00
[email protected]andGPT-6 022306f5d8 refactor(client): keep the library split without the separate mod API
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:03:46 +01:00
rdbtCVSandClaude Opus 5.5 60851159b2 Merge main into #5446
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:50:15 +00:00
rdbtCVSandClaude Opus 5.5 85c781e56e Far terrain client: a reconfiguration frees the old server's slots and rate
Inbox::reset, called on Event::Reconfiguring after clear_lod: requests a proxy's previous backend held are forgotten instead of holding in_flight slots for the 30 s timeout, and the next offer's per_second makes a new bucket.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:08:11 +11:00
rdbtCVSandClaude Opus 5.5 8e6281bb91 Merge #4945 (main merged) into #4946
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:51:26 +00:00
rdbtCVSandClaude Opus 5.5 7f4784397a Far terrain wire: columns hold main's Spans
#4747 made lod::Column::spans a SmallVec-backed Spans; the decoder builds one directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:48:13 +00:00
rdbtCVSandClaude Opus 5.5 dc1fc94a4c Merge main into #4945
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:40:44 +00:00
[email protected]andClaude Opus 5.5 1e0fe9bb79 Merge upstream/main; the game's unit tests keep their own process
docs/native-mods.md keeps main's API 58 notes, then the API mods section.

With the game module in the horizon-client library, its unit tests ran in the
library's test process and shared process-wide state with the library's tests
(health::HURT_SERVER, the sulfur cube archetypes): six tests failed. The game
module now builds into the library only outside tests; its unit tests stay in the
executable's test build, as before the move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:36:47 +01:00
[email protected]andClaude Opus 5.5 af806fd1c3 Merge upstream/main into native/api-mods
Conflicts: game.rs keeps the api_mods export beside main's narrator module;
horizon-threads takes main's windows-sys features (the same Windows fix);
native-mods.md keeps main's API 47-57 notes, then the API mods section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:09:44 +01:00
[email protected] 0fb25759e1 boot_paint: AddFontMemResourceEx only reads the font count
Clippy (unnecessary_mut_passed) now that boot_paint compiles on Windows.
2026-10-31 20:25:15 +01:00
[email protected] 3888ac4613 Native mods without mixins: API mods that never rebuild horizon-client
A native bundle with [api] (crate and entry) and no [[injection]] is an API mod: its
crate implements horizon_mod_api::Mod and the game asks it right after the item and
block hook points it is named after, once the mixin mods there are done.

- horizon-mod-api: the Mod trait (use_item, use_item_on, right_click_block,
  left_click_block, destroy_block, place_block, tooltip), Action and Host.
- horizon-client: the game module moves into the library and the executable only adds
  the allocator, so a build can link the game from another package; game/api_mods.rs
  dispatches, and a mod turned off in Options > Mods is skipped with no build.
- launcher: API mods skip horizon-mixins; their crate and a generated native-game
  package build the executable, so horizon-client never depends on a mod.
- horizon-mods: the manifest's optional api field and its checks.
- docs: native-mods.md "API mods (no mixins)", stability.md; example in
  launcher/tests/fixtures/native-api-demo.
2026-10-31 20:25:15 +01:00
TobiasLogicandClaude Opus 5.5 181d5f5ed6 Shader packs: four more translation fixes (sampler names, const parameters, arrays of arrays)
- A sampler named like a function (Bloop's `uniform sampler2D texture;` read as
  `texture(texture, uv)`) is the function where it is called, and the sampler elsewhere.
- A local named like a sampler, or like a sampler parameter, is in scope only after its
  initializer (GLSL 4.60 4.2.2): `float depth = texture(depth, c).x` still samples the parameter
  `depth` (Potato's depth-aware blur).
- A parameter's `const` (`const in float x`), which naga's parser refuses, is dropped: it only
  promises the function doesn't write the parameter.
- naga's frontend nests arrays of arrays the other way round (`float x[3][2]` comes out as 2
  arrays of 3; also `float[3][2]`, struct members and parameters), so indexing reads the wrong
  element or fails validation. Two or more dimensions in a type are written reversed, so the
  frontend's reversal restores GL's order; accesses are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:03:20 +07:00
[email protected] e2d852088b Windows build: the windows-sys features boot_paint uses
boot_paint.rs draws the boot screen with GDI (Win32_Graphics_Gdi) and reads the
window's size (Win32_UI_WindowsAndMessaging), which horizon-threads didn't turn on.
2026-10-31 19:47:15 +01:00
rdbtCVSandClaude Opus 5.5 71245c0aaf Merge origin/main into the far terrain server branch
lan.rs keeps both the encoder's mod update tag and the far terrain service.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:53:01 -06:00
389 changed files with 17293 additions and 1325 deletions

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+31
View File
@@ -0,0 +1,31 @@
name: mojang republish
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
# gh workflow run "mojang republish" -f dry_run=true
on:
schedule:
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: mojang-republish
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Check the manifest
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
+16 -8
View File
@@ -39,7 +39,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -56,6 +57,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
@@ -83,8 +85,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -101,8 +103,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -127,8 +132,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -159,7 +167,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+59
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1373,6 +1374,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
@@ -1478,6 +1535,7 @@ dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"log",
"serde",
"serde_json",
]
@@ -1513,6 +1571,7 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"regex",
]
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
@@ -39,6 +39,11 @@ pub enum Change {
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
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" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
@@ -1179,6 +1179,15 @@ impl Audio {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// The `uiSoundReserve` extra: positional sounds leave
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
pub fn set_listener_reserve(&mut self, on: bool) {
self.engine.listener_reserve = if on {
crate::sound_engine::HEARD_RESERVE
} else {
0
};
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
+121
View File
@@ -387,6 +387,52 @@ impl Biomes {
}
}
/// A biome's colours as a mod may change them (`native_biome_colors`), each
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
/// water.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BiomeColors {
pub grass: u32,
pub foliage: u32,
pub dry_foliage: u32,
pub water: u32,
}
impl Biomes {
/// This table with each biome's colours passed through `f`, given the
/// biome's name (`minecraft:plains`) and its colours; None when `f`
/// changed none. Bits past 0xFFFFFF are dropped.
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
let mut out = self.clone();
let mut changed = false;
for b in &mut out.list {
let before = BiomeColors {
grass: b.grass,
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
};
let mut colors = before;
f(&b.name, &mut colors);
let after = BiomeColors {
grass: colors.grass & 0xFF_FFFF,
foliage: colors.foliage & 0xFF_FFFF,
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
water: colors.water & 0xFF_FFFF,
};
if after != before {
changed = true;
b.grass = after.grass;
b.foliage = after.foliage;
b.dry_foliage = after.dry_foliage;
b.water = after.water;
}
}
changed.then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
@@ -534,6 +580,81 @@ mod pack_tests {
}
}
#[cfg(test)]
mod color_tests {
use super::*;
use serde_json::json;
fn biomes() -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let effects = |grass: u32, modifier: &str| {
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
"grass_color_modifier": modifier}})
};
let list = vec![
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
BiomeInfo::from_json(
"minecraft:dark_forest".into(),
&effects(0x91BD59, "dark_forest"),
&maps,
),
];
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn a_change_reaches_the_tints_the_mesher_reads() {
let autumn = biomes()
.with_colors(|name, c| {
c.foliage = 0xD2691E;
if name == "minecraft:plains" {
c.grass = 0xBFA055;
}
})
.expect("changed");
let plains = autumn.id("minecraft:plains");
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
assert_eq!(autumn.get(plains).water, 0x3F76E4);
// The dark forest's modifier still darkens its (unchanged) grass:
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
let dark = autumn.id("minecraft:dark_forest");
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
}
#[test]
fn no_change_gives_none_and_alpha_bits_are_dropped() {
assert!(biomes().with_colors(|_, _| {}).is_none());
assert!(
biomes()
.with_colors(|_, c| c.water |= 0xFF00_0000)
.is_none()
);
let blue = biomes()
.with_colors(|_, c| c.water = 0xFF00_00FF)
.expect("changed");
assert_eq!(blue.get(0).water, 0x0000FF);
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
+3 -1
View File
@@ -205,7 +205,9 @@ impl Brewing {
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
// The data's recipe filters, over the final set.
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
+115 -4
View File
@@ -501,6 +501,19 @@ impl Camera {
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
}
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
/// First-person views do not call `adjust_distance`.
pub fn align_with_entity_distance(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
adjust_distance: impl FnOnce(f32) -> f32,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
@@ -528,10 +541,14 @@ impl Camera {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
let adjusted = adjust_distance(vanilla);
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
adjusted
} else {
vanilla
};
let zoom = self.max_zoom(distance, &mut clip);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
@@ -969,6 +986,100 @@ mod tests {
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn detached_distance_override_keeps_collision_clipping() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: Some(LivingCamera {
scale: 2.0,
camera_distance: 3.0,
sleeping: None,
dead: None,
}),
mount: Some((3.0, 4.0)),
};
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
let mut camera = Camera::default();
let mut calls = 0;
let mut rays = 0;
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
assert_eq!(distance, 12.0);
8.0
},
|_, _| None,
);
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
camera.align_with_entity_distance(
&entity,
1.0,
kind,
|distance| {
calls += 1;
assert_eq!(distance, 12.0);
8.0
},
|_, _| {
rays += 1;
Some([0.0, 0.0, z])
},
);
assert_eq!((calls, rays), (1, 8));
assert!((camera.position[2] - z).abs() < 0.2);
}
}
#[test]
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| distance,
|_, _| None,
);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
}
let mut camera = Camera::default();
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::ThirdPersonBack,
|_| 0.0,
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
camera.align_with_entity_distance(
&entity,
1.0,
CameraType::FirstPerson,
|_| panic!("first person has no detached distance"),
|_, _| None,
);
assert_eq!(camera.position, camera.eye);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
+10 -2
View File
@@ -1,7 +1,15 @@
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
pub const CLOUD_HEIGHT: f64 = 192.33;
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
pub fn has_color(alpha: f32) -> bool {
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
}
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
pub fn visible(mode: &str, alpha: f32) -> bool {
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
mode != "false" && has_color(alpha)
}
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
@@ -110,7 +118,7 @@ impl Input<'_> {
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
+38 -1
View File
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -354,7 +374,8 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
);
crate::logging::flush();
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
@@ -636,6 +657,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+5 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -583,7 +583,7 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
@@ -591,7 +591,8 @@ fn content_id(file: &str) -> Option<String> {
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -628,6 +629,7 @@ impl DataStore {
"loot_table",
"predicate",
"recipe",
"recipe_filter",
"tags",
"trade_set",
"villager_trade",
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
@@ -141,6 +141,8 @@ pub struct Sky {
pub fog_end: f32,
/// `visual/cloud_fog_end_distance`.
pub cloud_fog_end: f32,
/// `visual/cloud_height`.
pub cloud_height: f32,
}
impl Environment {
pub fn load(root: &Path) -> Result<Self> {
@@ -309,6 +311,10 @@ impl Environment {
.base
.get("minecraft:visual/cloud_fog_end_distance")
.map_or(2048.0, |v| v[0]),
cloud_height: self
.base
.get("minecraft:visual/cloud_height")
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
}
}
}
+64 -7
View File
@@ -96,8 +96,13 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Positional sounds leave a few static sound channels free for UI sounds
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
pub ui_sound_reserve: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// No particles from breaking or mining a block (other particles stay).
pub hide_block_particles: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -113,6 +118,8 @@ pub struct Extras {
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,7 +145,9 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
ui_sound_reserve: false,
own_name_tag: false,
hide_block_particles: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
@@ -146,6 +155,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -213,7 +223,9 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
@@ -221,6 +233,7 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +241,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -254,7 +267,9 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.ui_sound_reserve),
bool_text(self.own_name_tag),
bool_text(self.hide_block_particles),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
@@ -263,6 +278,7 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -383,7 +399,9 @@ pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -393,6 +411,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -403,6 +423,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -410,6 +432,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(SortKey),
Err("Multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Ok(UiSoundReserve),
]
}
impl Item {
@@ -418,7 +442,9 @@ impl Item {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -426,16 +452,18 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
UiSoundReserve => return None,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -450,9 +478,15 @@ impl Item {
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::HideBlockParticles => {
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -471,6 +505,9 @@ impl Item {
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -481,10 +518,14 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::OwnNameTag
| Item::HideBlockParticles
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -494,13 +535,16 @@ impl Item {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -521,13 +565,16 @@ impl Item {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -549,7 +596,8 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -575,11 +623,20 @@ mod tests {
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
Item::HideBlockParticles.cycle(&mut e, false);
assert!(e.hide_block_particles);
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
let only = Extras::parse("hideBlockParticles:true\n");
assert!(only.hide_block_particles && !only.own_name_tag);
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+77 -1
View File
@@ -112,6 +112,8 @@ struct Region {
cursor: (u32, u32),
row: u32,
pending: Vec<Patch>,
/// A glyph did not fit since the last `Font::begin_frame`.
full: bool,
}
struct Cache {
/// Every font by id, the default one first (the common lookup).
@@ -546,6 +548,7 @@ impl Region {
self.row = 0;
}
if y + h > region.1 {
self.full = true;
return None;
}
let (ax, ay) = (x, region.0 + y);
@@ -603,6 +606,7 @@ impl Font {
cursor: (0, 0),
row: 0,
pending: Vec::new(),
full: false,
},
random: horizon_core::random::LegacyRandom::new(
std::time::SystemTime::now()
@@ -669,8 +673,19 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
cache.region.full = false;
self.generation.set(next_generation());
}
/// Call once per frame before any text is laid out. When the dynamic
/// region ran out of room (vanilla's glyph textures grow instead), the
/// glyphs are forgotten so they are rasterized again from the start; it
/// waits for the frame boundary because quads already queued hold the old
/// rects.
pub fn begin_frame(&self) {
if self.cache.borrow().region.full {
self.reset_cache();
}
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
@@ -706,7 +721,16 @@ impl Font {
if let Some(g) = set.glyphs.get(&c) {
return *g;
}
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.glyphs.insert(c, glyph);
glyph
}
@@ -1418,4 +1442,56 @@ mod tests {
font.reset_cache();
assert_eq!(font.glyph('a').advance, 2.0);
}
#[test]
fn a_full_glyph_region_recovers_at_the_next_frame() {
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
let sheet = png(16, 8, |x, _| x % 8 < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"full",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
let a = font.glyph('a');
assert!(a.rect.is_some());
// No room for 'b': missing for now, and asked again next time.
assert!(font.glyph('b').rect.is_none());
assert!(font.glyph('b').rect.is_none());
assert_eq!(font.epoch(), epoch);
font.begin_frame();
assert_ne!(font.epoch(), epoch);
let b = font.glyph('b');
assert!(b.rect.is_some());
assert_eq!(b.advance, a.advance);
// Nothing overflowed since: the next frame keeps the glyphs.
let epoch = font.epoch();
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
#[test]
fn a_character_without_a_provider_does_not_reset_the_region() {
let sheet = png(8, 8, |x, _| x < 4);
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"nofill",
&[
("assets/minecraft/font/default.json", default),
("assets/pack/textures/font/s.png", &sheet),
],
);
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
let _ = std::fs::remove_dir_all(&root);
let epoch = font.epoch();
assert!(font.glyph('z').rect.is_none());
font.begin_frame();
assert_eq!(font.epoch(), epoch);
}
}
File diff suppressed because it is too large. Load diff
@@ -299,6 +299,7 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
active: Option<&[u64]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if self.collect(tick, active) == 0 {
return;
@@ -341,7 +342,7 @@ impl SpriteAnimator {
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
@@ -859,7 +860,7 @@ mod tests {
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick, None);
animator.animate(&mut encoder, &mut uploads, tick, None, None);
writes.push(
animator
.sprites
+306 -18
View File
@@ -1,12 +1,21 @@
//! One vertex buffer for every terrain section: sections own ranges of it, so
//! a frame binds it once and draws every section with indirect multi-draw.
//! Translucent quads can have an arena of their own (`Arena::translucent`),
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
pub const GRAIN: u64 = 256;
/// The largest page, in bytes.
/// The largest page of the section arena, and of arenas shaders bind whole
/// as storage, in bytes.
pub const PAGE_BYTES: u64 = 256 << 20;
/// The largest page of an arena no shader binds as storage: under 2 GiB, where
/// DirectX 12 and OpenGL cap buffers (`i32::MAX`), with room for the bytes
/// wgpu-core adds to vertex buffers; the device's own limit still applies.
pub const LARGE_PAGE_BYTES: u64 = (1 << 31) - (1 << 20);
/// Free vertex ranges: start -> length, never adjacent.
#[derive(Debug, PartialEq)]
@@ -118,7 +127,7 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -127,16 +136,54 @@ impl Arena {
} else {
wgpu::BufferUsages::INDEX
};
Self::with_usage(device, 4, capacity, usage)
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
}
/// Sorted indices of the translucent arena's quads: pages up to
/// `LARGE_PAGE_BYTES` as its own, so its draws keep to one index page too.
pub fn translucent_indices(device: &wgpu::Device, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::INDEX;
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
page_bytes: u64,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(PAGE_BYTES);
// Pages of `page_bytes`, or less where the device's buffers (or the
// storage bindings that read them whole) are smaller.
let limits = device.limits();
let mut bytes = limits.max_buffer_size.min(page_bytes);
if usage.contains(wgpu::BufferUsages::STORAGE) {
bytes = bytes.min(limits.max_storage_buffer_binding_size);
}
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
@@ -151,11 +198,16 @@ impl Arena {
video_memory_full: false,
}
}
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
/// A buffer of `capacity` vertices in video memory or, when that is full,
/// `spill` allows it and so does the device, in system memory: Vulkan
/// drivers fail an allocation rather than page like OpenGL ones, and
/// wgpu's default error handler panics. `None` when neither has room.
fn create(
&mut self,
device: &wgpu::Device,
capacity: u64,
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -167,9 +219,10 @@ impl Arena {
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
if !spill
|| !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
@@ -234,7 +287,7 @@ impl Arena {
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
let grown = self.grown(current, len);
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, encoder, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
@@ -250,9 +303,9 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let ((buffer, host), capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity, true) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None if capacity > len => (self.create(device, len, true)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
@@ -392,8 +445,17 @@ impl Arena {
}
}
}
/// The capacity the newest page grows to for a range of `len`: double, but
/// by at most `PAGE_BYTES` past that size, so a large page is never left
/// half empty (nor held twice during the copy) by hundreds of MiB.
fn grown(&self, current: u64, len: u64) -> u64 {
let step = current.min(PAGE_BYTES / self.stride);
(current + step).max(current + len).min(self.page_limit)
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
/// there is no memory for it. A page past `PAGE_BYTES` grows in video
/// memory only: moving all of it to system memory would slow every frame
/// that draws it, so a new page takes the range instead (and may spill).
fn grow(
&mut self,
device: &wgpu::Device,
@@ -402,7 +464,8 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some((buffer, host)) = self.create(device, capacity) else {
let spill = capacity * self.stride <= PAGE_BYTES;
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -474,6 +537,26 @@ mod tests {
.ok()
}
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.ok()
}
/// `device` and `device_at_limits`: the default limits bind 128 MiB of a storage
/// buffer, half their 256 MiB buffers, so storage pages shrink to the binding there;
/// the adapter's own let large pages past 256 MiB.
fn devices() -> Vec<(wgpu::Device, wgpu::Queue)> {
let devices = [device(), device_at_limits()];
devices.into_iter().flatten().collect()
}
/// `Arena::alloc`, with the copies of a grow submitted at once.
fn alloc(
arena: &mut Arena,
@@ -588,6 +671,119 @@ mod tests {
}
}
/// The renderer's half of that compaction (`move_off_page`) moves what each
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let section = |(page, start), translucent_at, solid, translucent| SectionGpu {
page,
start,
translucent_at,
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
for split in [true, false] {
let mut arena = Arena::new(&device, stride, 4 * GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Page 0: two sections' grains and two free ones; page 1: one taken.
let (_, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, b) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
let (_, rest) = alloc(&mut arena, &device, &queue, 2 * GRAIN).unwrap();
assert_eq!(alloc(&mut arena, &device, &queue, GRAIN), Some((1, 0)));
arena.release(0, rest, 2 * GRAIN);
// Split, a grain of solid quads each (the first's translucent ones, and
// all of a water section's, in the translucent arena); else a grain of
// solid and translucent quads, and one of translucent quads alone.
let mut sections = if split {
vec![
section((0, a), (0, 0), 64, 64),
section((0, 0), (0, GRAIN), 0, 64),
section((0, b), (0, 0), 64, 0),
]
} else {
vec![
section((0, a), (0, a + 128), 32, 32),
section((0, b), (0, b), 0, 64),
]
};
// A range's first and last vertex hold its section's number, and the
// first translucent one of a mixed range that plus 100.
let write = |(page, at): (u32, u64), value: usize| {
let bytes = (value as u32).to_le_bytes();
queue.write_buffer(arena.buffer(page), at * stride, &bytes);
};
for (i, s) in sections.iter().enumerate() {
if let Some((page, start, len)) = s.vertex_ranges(split).0 {
write((page, start), i);
write((page, start + len - 1), i);
if !split && s.solid > 0 && s.translucent > 0 {
write(s.translucent_at, 100 + i);
}
}
}
let before: Vec<_> = sections
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
before[i],
"split {split}"
);
continue;
};
assert_eq!(page, 1, "split {split}");
assert_eq!(word((page, start)), i as u32);
assert_eq!(word((page, start + len - 1)), i as u32);
if !split {
assert_eq!(s.translucent_at, (page, start + u64::from(s.solid) * 4));
if s.solid > 0 {
assert_eq!(word(s.translucent_at), 100 + i as u32);
}
}
}
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
assert!(!arena.has_page(0), "split {split}");
assert!(arena.alloc_existing(GRAIN).is_some(), "split {split}");
assert_eq!(arena.alloc_existing(GRAIN), None, "split {split}");
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -630,6 +826,96 @@ mod tests {
assert!(arena.pages.is_empty());
}
/// Every arena's pages stay within its cap and the device's limits: the
/// whole-page storage bindings of flat draws and shader packs included.
#[test]
fn pages_stay_within_their_caps_and_the_device_limits() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let bytes = |a: &Arena| a.page_vertices() * a.stride;
for (device, _) in &devices {
let limits = device.limits();
for storage in [false, true] {
for arena in [
Arena::new(device, 24, GRAIN, storage),
Arena::indices(device, GRAIN, storage),
] {
assert!(bytes(&arena) <= PAGE_BYTES.min(limits.max_buffer_size));
let binding = limits.max_storage_buffer_binding_size;
assert!(!storage || bytes(&arena) <= binding);
}
}
for large in [
Arena::translucent(device, 24, GRAIN),
Arena::translucent_indices(device, GRAIN),
] {
assert!(bytes(&large) <= LARGE_PAGE_BYTES.min(limits.max_buffer_size));
assert!(!large.usage.contains(wgpu::BufferUsages::STORAGE));
}
}
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
if devices.is_empty() {
eprintln!("no GPU adapter: skipped");
return;
}
let mut exercised = false;
for (device, _) in &devices {
let limits = device.limits();
let large = limits.max_buffer_size > PAGE_BYTES;
exercised |= large;
for flat in [false, true] {
for split in [false, true] {
let case = format!("large {large}, flat {flat}, split {split}");
let (indices, translucent) = Arena::translucent_arenas(device, 24, flat, split);
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
}
}
}
if !exercised {
eprintln!("buffers end at PAGE_BYTES here: large pages not exercised");
}
}
/// Pages double up to `PAGE_BYTES`, then grow by `PAGE_BYTES` at most.
#[test]
fn large_pages_grow_by_bounded_steps() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
// Exercise the policy past the test device's own buffer limit.
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let step = PAGE_BYTES / 24;
assert_eq!(arena.grown(1 << 18, GRAIN), 1 << 19);
assert_eq!(arena.grown(step, GRAIN), 2 * step);
assert_eq!(arena.grown(3 * step, GRAIN), 4 * step);
assert_eq!(arena.grown(3 * step, step + GRAIN), 4 * step + GRAIN);
assert_eq!(
arena.grown(arena.page_limit - GRAIN, GRAIN),
arena.page_limit
);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -638,7 +924,9 @@ mod tests {
};
for mut arena in [
Arena::new(&device, 28, GRAIN, false),
Arena::translucent(&device, 24, GRAIN),
Arena::indices(&device, GRAIN, false),
Arena::translucent_indices(&device, GRAIN),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
+6 -1
View File
@@ -528,7 +528,11 @@ impl Cull {
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn test_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
@@ -539,6 +543,7 @@ impl Cull {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -258,13 +258,20 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
let mut pass = begin(
encoder,
"Entity outline",
&t.outline,
clear,
timing.as_deref_mut(),
);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -280,7 +287,7 @@ impl EntityOutline {
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
@@ -293,6 +300,7 @@ fn begin<'e>(
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -305,6 +313,7 @@ fn begin<'e>(
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &attachments,
..Default::default()
})
+42 -5
View File
@@ -62,7 +62,13 @@ pub struct Call {
}
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -286,7 +292,7 @@ fn plan(
}
impl Flat {
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -297,6 +303,7 @@ impl Flat {
&wgpu::PipelineLayout,
&str,
&str,
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
) -> wgpu::RenderPipeline,
@@ -354,11 +361,35 @@ impl Flat {
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"fs_solid",
None,
back,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"fs_opaque",
None,
back,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"fs_solid",
None,
back,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
@@ -366,6 +397,7 @@ impl Flat {
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"vs_terrain_flat",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
@@ -490,6 +522,7 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
@@ -668,7 +701,7 @@ impl Flat {
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
@@ -700,13 +733,17 @@ impl Flat {
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn flatten_phase2(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
@@ -1,9 +1,11 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
@@ -10,8 +10,8 @@
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
@@ -442,6 +445,7 @@ impl Interpolation {
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
@@ -644,10 +648,12 @@ mod tests {
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
@@ -682,6 +688,35 @@ mod tests {
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
@@ -731,6 +766,7 @@ mod tests {
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
@@ -776,7 +812,8 @@ mod tests {
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
@@ -795,7 +832,26 @@ mod tests {
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
@@ -24,7 +24,8 @@
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
@@ -85,6 +86,8 @@ struct Params {
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
+17 -4
View File
@@ -366,7 +366,11 @@ impl Hdr {
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn keep_world(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -387,6 +391,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
color_attachments: &attachments,
..Default::default()
});
@@ -397,7 +402,11 @@ impl Hdr {
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
pub fn mark_hand(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
};
@@ -413,6 +422,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
color_attachments: &attachments,
..Default::default()
});
@@ -434,6 +444,7 @@ impl Hdr {
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else {
return;
@@ -443,7 +454,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
);
Self::draw(encoder, &self.pipeline, &t.group, target);
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
}
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
@@ -494,7 +505,7 @@ impl Hdr {
0,
bytemuck::cast_slice(&self.params(world, true)),
);
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
Some(texture)
}
@@ -528,6 +539,7 @@ impl Hdr {
pipeline: &wgpu::RenderPipeline,
group: &wgpu::BindGroup,
target: &wgpu::Texture,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -541,6 +553,7 @@ impl Hdr {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
color_attachments: &attachments,
..Default::default()
});
+1 -1
View File
@@ -1,4 +1,4 @@
//! Far meshes share the terrain arena and passes, with their actual bounds
//! Far meshes share the terrain arenas and passes, with their actual bounds
//! rather than the 16³ boxes used by the near-section residency walk.
use super::{Frustum, Renderer, SectionGpu};
use crate::{
@@ -102,16 +102,38 @@ fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32>
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
/ f32(textureDimensions(horizon_pbr_normals).x);
let l = max(lod - log2(scale), 0.0);
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
}
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
}
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let s = horizon_terrain_sprite_sampler;
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
switch layer / horizon_terrain_SPRITE_SPLIT {
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the maps: their own texels, as the sampler would pick.
let texels = in.uv * horizon_block_pbr_size();
// The mip level of this point in the texture: its texels, as the sampler would pick.
let texels = in.uv * horizon_block_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
@@ -225,8 +225,9 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
@@ -236,7 +237,7 @@ impl ShadowMap {
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
}
fn depth_pass<'e>(
@@ -244,9 +245,11 @@ impl ShadowMap {
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
@@ -266,6 +269,7 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
@@ -274,6 +278,7 @@ impl ShadowMap {
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes(label)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
@@ -298,8 +303,8 @@ impl ShadowMap {
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
}
}
}
+15 -3
View File
@@ -620,6 +620,7 @@ impl Oit {
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
@@ -645,7 +646,7 @@ impl Oit {
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
@@ -680,6 +681,7 @@ impl Oit {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
@@ -692,6 +694,7 @@ impl Oit {
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
@@ -710,7 +713,7 @@ impl Oit {
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
@@ -720,16 +723,18 @@ impl Oit {
depth,
(label, group, &then),
(clouds, cloud_depth),
timing.as_deref_mut(),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
@@ -749,6 +754,7 @@ impl Oit {
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
#[allow(clippy::too_many_arguments)]
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -764,6 +770,7 @@ impl Oit {
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
@@ -774,6 +781,7 @@ impl Oit {
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
@@ -798,6 +806,7 @@ impl Oit {
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(terrain) = &t.terrain_depth else {
return;
@@ -810,6 +819,7 @@ impl Oit {
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
@@ -840,6 +850,7 @@ impl Oit {
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
@@ -852,6 +863,7 @@ impl Oit {
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
@@ -217,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -3117,8 +3117,7 @@ impl PackPost {
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
self.shadow_targets.as_ref()?;
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
if !self.has_shadow() {
return None;
}
let shadow = &self.uniforms.shadow;
@@ -3139,6 +3138,12 @@ impl PackPost {
self.terrain.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
pub fn has_shadow(&self) -> bool {
self.shadow_targets.is_some()
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
}
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
/// What the world's background is when the pack draws the sky: colortex0's clear colour
/// (`None`: the fog colour, its default).
@@ -1,5 +1,6 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! GPU time of each pass of a shader pack, of either kind, and of Horizon's own passes: shown in
//! the F3 overlay while it is open, and logged every `REPORT` timed frames with
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
@@ -139,6 +140,12 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
@@ -274,6 +281,15 @@ fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'
}
}
/// The compute pass's twin of `stamps`.
fn compute_stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::ComputePassTimestampWrites<'_> {
wgpu::ComputePassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
@@ -35,10 +35,35 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// `isRightHanded` (the Main Hand option) and `is_on_ground`.
pub right_handed: bool,
pub on_ground: bool,
/// The dimension type's lowest block (`bedrockLevel`), build height (`heightLimit`),
/// logical height, sky light and ceiling.
pub min_y: i32,
pub height: i32,
pub logical_height: i32,
pub has_skylight: bool,
pub has_ceiling: bool,
/// `isElytraFlying`: the player is fall flying.
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
/// has alpha (`LevelExtractor`); a dimension without clouds has no height, which the uniform
/// reports as NaN. NaN is not vanilla's own value: packs map it to "no clouds".
pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
if crate::clouds::has_color(cloud_color_alpha) {
height
} else {
f32::NAN
}
}
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -173,6 +198,15 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
"heightLimit",
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"isElytraFlying",
"cloudHeight",
];
/// Values carried from frame to frame.
@@ -380,6 +414,13 @@ impl FrameUniforms {
"relativeEyePosition" => v3((frame.camera - frame.eye).as_vec3()),
"playerLookVector" | "playerBodyVector" => v3(look),
"firstPersonCamera" => Value::i(&[i32::from(frame.first_person)]),
"isRightHanded" => Value::i(&[i32::from(inputs.right_handed)]),
"is_on_ground" => Value::i(&[i32::from(inputs.on_ground)]),
"bedrockLevel" => Value::i(&[inputs.min_y]),
"heightLimit" => Value::i(&[inputs.height]),
"logicalHeightLimit" => Value::i(&[inputs.logical_height]),
"hasSkylight" => Value::i(&[i32::from(inputs.has_skylight)]),
"hasCeiling" => Value::i(&[i32::from(inputs.has_ceiling)]),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
@@ -439,6 +480,8 @@ impl FrameUniforms {
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
@@ -533,6 +576,25 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
assert_eq!(cloud_height(0.8, 120.0), 120.0);
assert!(cloud_height(0.0, 192.33).is_nan());
}
#[test]
fn elytra_and_cloud_height_uniforms_are_written() {
assert!(NAMES.ends_with(&["isElytraFlying", "cloudHeight"]));
let mut out = vec![0u8; 16];
Value::i(&[1]).write("bool", &mut out, 0);
assert_eq!(i32::from_ne_bytes(out[0..4].try_into().unwrap()), 1);
Value::f(&[192.33]).write("float", &mut out, 4);
assert_eq!(f32::from_ne_bytes(out[4..8].try_into().unwrap()), 192.33);
Value::f(&[f32::NAN]).write("float", &mut out, 8);
assert!(f32::from_ne_bytes(out[8..12].try_into().unwrap()).is_nan());
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
@@ -437,14 +437,15 @@ impl Post {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
@@ -460,6 +461,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
@@ -467,7 +469,7 @@ impl Post {
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
self.finish_with(encoder, screen, &taa.show[i], timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
@@ -476,6 +478,7 @@ impl Post {
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
@@ -488,6 +491,7 @@ impl Post {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
timestamp_writes: timing.and_then(|t| t.writes("Post")),
color_attachments: &attachments,
..Default::default()
});
+25 -1
View File
@@ -325,6 +325,19 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -415,6 +428,11 @@ pub struct Graphics {
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -454,6 +472,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -544,6 +563,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -560,7 +580,7 @@ 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:{}\n",
"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",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -600,6 +620,7 @@ impl Graphics {
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
@@ -747,10 +768,13 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
+4
View File
@@ -2017,9 +2017,11 @@ impl HudRenderer {
batch,
size,
0..batch.vertices.len() as u32,
None,
);
}
/// Draw part of the prepared batch, preserving its blend/depth runs.
#[allow(clippy::too_many_arguments)]
pub fn draw_range(
&self,
encoder: &mut wgpu::CommandEncoder,
@@ -2028,6 +2030,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
vertices: std::ops::Range<u32>,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
if vertices.is_empty() || size.contains(&0) {
return;
@@ -2058,6 +2061,7 @@ impl HudRenderer {
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
+277 -25
View File
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
/// columns: a server refused or lost it) waits before it is asked again.
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
@@ -494,7 +497,20 @@ struct Plan {
/// selection is rebuilt, without sorting the whole set.
by_level: [Vec<TileKey>; MAX_LEVEL as usize + 1],
elevation: i32,
camera: [i32; 2],
/// The squared distance within which no coarser tile stands in for
/// missing detail (`stands_in`): the seam with the near chunks waits
/// for its own tiles instead of showing broad boxes beside them.
near: i64,
/// The squared distance within which planned tiles load before the
/// coarse backdrop (`wanted_tiles`).
first: i64,
}
/// Chunks past the render distance where only planned tiles are drawn (two
/// past the last near chunk, `streaming::in_view_distance`), and where they
/// load first.
const SEAM_CHUNKS: i32 = 3;
const FIRST_CHUNKS: i32 = 4;
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
@@ -518,6 +534,9 @@ impl Plan {
let size = 16 << level;
let mut result = Self {
elevation,
camera,
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
..Self::default()
};
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
@@ -589,7 +608,7 @@ impl Plan {
if complete {
return true;
}
if ready.contains_key(&key) {
if ready.contains_key(&key) && self.stands_in(key) {
// Its mesh is clipped around every selected finer tile.
out.push(key);
return true;
@@ -602,6 +621,26 @@ impl Plan {
false
}
}
/// Whether a split tile may draw for its missing children: only away
/// from the near chunks, where its cells are small on screen.
fn stands_in(&self, key: TileKey) -> bool {
key.reach(self.camera) >= self.near
}
/// The coarse tile sampled to cover `leaf` while it loads: two levels
/// up (16 times fewer tiles, each as cheap as a leaf to sample), one
/// where that would reach the seam, none beside it.
fn backdrop(&self, leaf: TileKey) -> Option<TileKey> {
let mut backdrop = None;
let mut key = leaf;
for _ in 0..2 {
key = key.parent();
if !self.nodes.contains(&key) || !self.stands_in(key) {
break;
}
backdrop = Some(key);
}
backdrop
}
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
/// at level 0, also `filled`) rather than by a coarser ancestor standing
/// in for it, or is past the far terrain.
@@ -625,6 +664,47 @@ impl Plan {
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
}
}
/// The tiles to sample, `Wanted`'s order best first. First the planned
/// tiles at the seam with the near chunks, nearest first: coarse tiles
/// there cost a far tile's work for broad boxes that fine tiles replace
/// moments later. Then a coarse backdrop (`Plan::backdrop`) out to the
/// horizon, then every other planned tile nearest first, each replacing its
/// part of the backdrop: the horizon fills in early and sharpens from the
/// player outward, whichever way they look.
fn wanted_tiles(
plan: &Plan,
ready: &FxHashMap<TileKey, impl Sized>,
camera: [i32; 2],
) -> impl Iterator<Item = Wanted> {
let wanted = |class, key: TileKey| Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
};
let mut backdrop = FxHashSet::default();
let mut tiles: Vec<_> = plan
.nodes
.iter()
.filter(|key| !plan.split.contains(key) && !ready.contains_key(key))
.map(|&key| {
if let Some(coarse) = plan.backdrop(key)
&& !ready.contains_key(&coarse)
{
backdrop.insert(coarse);
}
let first = key.reach(camera) < plan.first;
wanted(if first { SEAM } else { REST }, key)
})
.collect();
tiles.extend(backdrop.into_iter().map(|key| wanted(BACKDROP, key)));
tiles.into_iter()
}
/// `Wanted::class`es, best first.
const SEAM: u8 = 0;
const BACKDROP: u8 = 1;
const REST: u8 = 2;
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
@@ -791,9 +871,11 @@ impl Ord for MeshQueued {
.then(other.order.cmp(&self.order))
}
}
/// A tile the sampler should produce next, nearest first.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
/// A tile the sampler should produce next: by `class`
/// (`wanted_tiles`), then nearest first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Wanted {
class: u8,
distance: i64,
x: i32,
z: i32,
@@ -843,6 +925,8 @@ pub struct Lod {
near_loading: bool,
/// When `request_samples` last started a job while held.
held_sample_at: std::time::Instant,
/// Tiles whose sampling failed, and when they may be asked again.
retry: FxHashMap<TileKey, std::time::Instant>,
/// Each tile's samples, shared with mesh jobs in flight.
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
/// Fully covered subtrees, by tile.
@@ -1179,6 +1263,7 @@ impl Lod {
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
@@ -1411,14 +1496,15 @@ impl Lod {
let present = self.ready.contains_key(&key);
// Covered level-0 stand-ins complete their parent but are never drawn.
let ready = present && !(key.level == 0 && self.covered.contains(&(key.x, key.z)));
// Mirrors `Plan::select`: a ready split tile is a complete
// stand-in even while its children are still incomplete, so no
// second fallback is drawn above it.
// Mirrors `Plan::select`: a ready split tile away from the seam is a
// complete stand-in even while its children are still incomplete,
// so no second fallback is drawn above it.
let (complete, active) = if self.plan.split.contains(&key) {
let children = key.children().into_iter().all(|child| {
!self.plan.nodes.contains(&child)
|| self.complete.get(&child).copied().unwrap_or(false)
});
let ready = ready && self.plan.stands_in(key);
(children || ready, ready && !children)
} else {
(present, ready)
@@ -1609,7 +1695,16 @@ impl Lod {
}
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
self.pending.remove(&key);
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
if samples.len() != CELLS * CELLS {
let now = std::time::Instant::now();
if self.retry.len() > 4096 {
self.retry.retain(|_, at| *at > now);
}
self.retry.insert(key, now + RETRY);
return;
}
self.retry.remove(&key);
if self.plan.nodes.contains(&key) {
let mut samples = samples;
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
for i in 0..256 {
@@ -2161,8 +2256,16 @@ impl Lod {
if free == 0 {
return;
}
let now = std::time::Instant::now();
if self.held() {
self.held_sample_at = std::time::Instant::now();
self.held_sample_at = now;
}
// A tile whose sampling failed waits out `RETRY`; once it has, the
// queue is rebuilt so the tile is asked again.
let retrying = self.retry.len();
self.retry.retain(|_, at| *at > now);
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// The queue is rebuilt when the view moves a chunk, not scanned and
// sorted on every frame.
@@ -2183,12 +2286,12 @@ impl Lod {
z: wanted.z,
level: wanted.level,
};
// Final tiles only: a coarse tile under the near area is work
// that finer tiles replace moments later.
// Final tiles and the backdrop only (`wanted_tiles`).
if self.pending.contains(&key)
|| self.ready.contains_key(&key)
|| self.plan.split.contains(&key)
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
{
continue;
}
@@ -2203,17 +2306,19 @@ impl Lod {
}
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
self.wanted.clear();
for &key in &self.plan.nodes {
if self.plan.split.contains(&key) || self.ready.contains_key(&key) {
continue;
}
self.wanted.push(std::cmp::Reverse(Wanted {
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
}));
}
// A failed tile waits out its retry delay (`retry`) before it is asked again.
let retry = &self.retry;
self.wanted.extend(
wanted_tiles(&self.plan, &self.ready, camera)
.filter(|w| {
!retry.contains_key(&TileKey {
x: w.x,
z: w.z,
level: w.level,
})
})
.map(std::cmp::Reverse),
);
}
#[cfg(test)]
@@ -2607,14 +2712,14 @@ mod tests {
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
};
// 24 chunks out, now past the render distance: its planned tile was
// split before, so never sampled, and a root stands in for it.
// split before, so never sampled. The ready root reaches the seam, so
// it stands in for nothing: the column stays until its tile is in.
let column = (24, 0);
let mut selected = Vec::new();
for &root in &after.roots {
after.select(root, &ready, &mut selected);
}
let drawn = selected.iter().filter(|key| contains(key, column));
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
assert!(!selected.iter().any(|key| contains(key, column)));
assert!(!after.replaces(column, &ready, |_| true));
let planned = TileKey {
x: 12,
@@ -3258,6 +3363,153 @@ mod tests {
Ok(())
}
#[test]
fn far_cells_past_render_distance_12_start_one_block_wide() {
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
// keeps 1 to the near terrain's edge and 2 to 256.
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
let distance = key.distance([8, 8]);
if distance < one {
assert_eq!(key.size() / 16, 1, "{key:?} reduced {reduced}");
} else if distance < two {
assert!(key.size() / 16 <= 2, "{key:?} reduced {reduced}");
}
}
}
}
/// Loads a render distance 12 view one sample job at a time, in
/// `wanted_tiles`' order. Counts the jobs, and their density lattice
/// points (each job's cost), before every far column within two chunks
/// of the near terrain is drawn as finely as the plan wants it (one
/// block wide in Full): until then nothing is drawn there. And before
/// every far column is drawn, by its tile or a coarse stand-in: until
/// then the far terrain ends short of the horizon. Checks that no coarse
/// tile is ever drawn at the seam.
fn jobs_until_fine_seam(camera: [i32; 2], reduced: bool) -> ([(usize, i32); 2], usize) {
let (detail, distance) = (12, 128);
let center = (camera[0].div_euclid(16), camera[1].div_euclid(16));
let mut covered = FxHashSet::default();
for z in center.1 - detail..=center.1 + detail {
for x in center.0 - detail..=center.0 + detail {
if crate::streaming::in_view_distance(center, detail, x, z) {
covered.insert((x, z));
}
}
}
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
let leaf = |x: i32, z: i32| {
(0..=MAX_LEVEL)
.map(|level| TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
.find(|key| plan.nodes.contains(key) && !plan.split.contains(key))
};
let mut seam = Vec::new();
for z in (camera[1] - 256..camera[1] + 256).step_by(2) {
for x in (camera[0] - 256..camera[0] + 256).step_by(2) {
let chunk = (x.div_euclid(16), z.div_euclid(16));
let near = (-2..=2)
.any(|dz| (-2..=2).any(|dx| covered.contains(&(chunk.0 + dx, chunk.1 + dz))));
if !covered.contains(&chunk) && near {
seam.push((x, z, leaf(x, z).unwrap()));
}
}
}
let mut horizon = Vec::new();
for z in (camera[1] - 2048..camera[1] + 2048).step_by(64) {
for x in (camera[0] - 2048..camera[0] + 2048).step_by(64) {
if !covered.contains(&(x.div_euclid(16), z.div_euclid(16))) && leaf(x, z).is_some()
{
horizon.push((x, z));
}
}
}
let mut ready: FxHashMap<TileKey, ()> = FxHashMap::default();
for key in &plan.nodes {
if key.level == 0 && covered.contains(&(key.x, key.z)) {
ready.insert(*key, ());
}
}
let leaves = plan
.nodes
.iter()
.filter(|k| !plan.split.contains(k))
.count();
let (mut cost, mut fine, mut reached) = (0, None, None);
for jobs in 0.. {
let mut selected = Vec::new();
for &root in &plan.roots {
plan.select(root, &ready, &mut selected);
}
let active: FxHashSet<_> = selected.into_iter().collect();
let drawn = |x: i32, z: i32, levels: u8| {
(0..=levels).any(|level| {
active.contains(&TileKey {
x: x.div_euclid(16 << level),
z: z.div_euclid(16 << level),
level,
})
})
};
for &(x, z, leaf) in &seam {
assert!(drawn(x, z, leaf.level) || !drawn(x, z, MAX_LEVEL));
}
if fine.is_none() && seam.iter().all(|&(x, z, leaf)| drawn(x, z, leaf.level)) {
fine = Some((jobs, cost));
}
if reached.is_none() && horizon.iter().all(|&(x, z)| drawn(x, z, MAX_LEVEL)) {
reached = Some((jobs, cost));
}
if let (Some(fine), Some(reached)) = (fine, reached) {
return ([fine, reached], leaves);
}
let next = wanted_tiles(&plan, &ready, camera).min().unwrap();
let next = TileKey {
x: next.x,
z: next.z,
level: next.level,
};
// `lod_grid`'s density lattice: one point per max(stride, 4) blocks.
let stride = next.size() / 16;
let lattice = 16 * stride / stride.max(4) + 1;
cost += lattice * lattice;
ready.insert(next, ());
}
unreachable!()
}
#[test]
fn the_far_terrain_next_to_the_near_chunks_loads_first() {
// Measured: 270 to 301 jobs, all of them fine tiles (7500 to 8500
// lattice points). A coarse tile first costs up to 289 points; the
// order that filled a ring ahead first and refined broad tiles every
// other job took 54000 to 119000 for the seam in view alone.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([(jobs, cost), _], _) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 320, "{jobs} jobs");
assert!(cost < 9000, "{cost} lattice points");
}
}
}
#[test]
fn a_coarse_backdrop_reaches_the_horizon_early() {
// Sampling only planned tiles, nearest first, reached the horizon
// with the last of ~3000 jobs in Full (500000 lattice points), ~1050
// in Reduced (200000): for minutes on a slow machine the far terrain
// ended in a hard edge. The backdrop adds ~230 coarse tiles (~80 in
// Reduced), about a tenth more work in all.
for camera in [[8, 8], [40, -9], [-300, 517]] {
for reduced in [false, true] {
let ([_, (jobs, cost)], leaves) = jobs_until_fine_seam(camera, reduced);
assert!(jobs < 1000 && jobs < leaves, "{jobs} jobs of {leaves}");
assert!(cost < 100_000, "{cost} lattice points");
}
}
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
+8 -1
View File
@@ -160,7 +160,14 @@ impl Mesher {
for slot in &mut faces {
let Some(face) = matching.next() else { break };
*slot = Some(Style {
layer: face.layer as u16 | if leaves { OPAQUE_LAYER } else { 0 },
// As the mesher marks them: opaque leaves and faces over opaque
// texels skip the cutout and draw in the opaque pass.
layer: face.layer as u16
| if leaves || face.opaque {
OPAQUE_LAYER
} else {
0
},
tint: if face.quad.tint_index >= 0 {
tint
} else {
@@ -59,6 +59,55 @@ pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// `--player-dir DIR` (#4952), set once at start: one player's own files, when several games
/// play in one game folder at the same time (the launcher's `play --account`).
static PLAYER: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes [`player_dir`] return `dir` for the rest of the run (`--player-dir`), creating it.
/// What is a player's own goes there: `options.txt` and the settings files beside it,
/// `logs/`, `crash-reports/`, `horizon-player.txt`, `servers.dat`, `hotbar.nbt` and
/// `screenshots/`. What the game folder's players share stays in it: saves, resource and
/// shader packs, mods and their config, caches.
pub fn set_player_dir(dir: PathBuf) {
let _ = fs::create_dir_all(&dir);
let _ = PLAYER.set(dir);
}
/// The folder of this player's own files: `--player-dir`, else the game folder (the working
/// directory, where the launcher runs the game).
pub fn player_dir() -> PathBuf {
PLAYER
.get()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
/// `--player-dir`, if given.
pub fn player_dir_override() -> Option<&'static Path> {
PLAYER.get().map(PathBuf::as_path)
}
/// Where the files all players share live, for settings kept beside `options.txt` in
/// `options_dir` (the enabled mods): that folder, unless `--player-dir` moved `options.txt`
/// out of the game folder.
pub fn shared_dir(options_dir: &Path) -> PathBuf {
shared_dir_of(PLAYER.get().map(PathBuf::as_path), options_dir, || {
std::env::current_dir().unwrap_or_default()
})
}
/// [`shared_dir`] for a given `--player-dir` and game folder.
fn shared_dir_of(
player: Option<&Path>,
options_dir: &Path,
game_dir: impl FnOnce() -> PathBuf,
) -> PathBuf {
match player {
Some(player) if player == options_dir => game_dir(),
_ => options_dir.to_path_buf(),
}
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
@@ -567,6 +616,20 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn a_player_dir_keeps_the_shared_files_in_the_game_folder() {
let (game, player) = (Path::new("/games/shared"), Path::new("/games/players/alex"));
// options.txt moved to the player's folder: the enabled mods stay the game folder's.
assert_eq!(shared_dir_of(Some(player), player, || game.into()), game);
// No --player-dir, or a splitscreen profile's own folder: beside options.txt as before.
assert_eq!(shared_dir_of(None, game, || unreachable!()), game);
let profile = Path::new("/games/shared/splitscreen/profiles/P2");
assert_eq!(
shared_dir_of(Some(player), profile, || unreachable!()),
profile
);
}
#[test]
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
let (mut floods, start) = (Floods::default(), Instant::now());
@@ -172,6 +172,7 @@ impl MenuBlur {
target: &wgpu::TextureView,
size: [u32; 2],
radius: f32,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
encoder.copy_texture_to_texture(
@@ -224,6 +225,7 @@ impl MenuBlur {
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
@@ -282,6 +282,7 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
@@ -292,6 +293,7 @@ impl SectionMesh {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
@@ -1264,6 +1264,8 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1526,6 +1528,8 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1571,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1634,6 +1639,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
_ => return None,
})
}
@@ -2166,6 +2172,7 @@ impl OptionsUi {
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -2255,6 +2262,14 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2321,6 +2336,17 @@ impl OptionsUi {
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
None,
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -6788,6 +6814,7 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Agent,
]
);
@@ -1024,6 +1024,7 @@ impl PostEffects {
size: [u32; 2],
depth: Option<&wgpu::TextureView>,
req: &Request,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) -> bool {
self.use_root(req.root);
let mut applied = Vec::new();
@@ -1099,6 +1100,7 @@ impl PostEffects {
(&work.1, size),
0.0,
format,
timing.as_deref_mut(),
);
self.staging = Some(staging);
for id in &applied {
@@ -1113,6 +1115,7 @@ impl PostEffects {
depth,
req.menu_blur_radius,
format,
timing.as_deref_mut(),
);
self.chains.insert(id.clone(), Some(chain));
}
@@ -1126,6 +1129,7 @@ impl PostEffects {
(target, size),
0.0,
format,
timing,
);
self.blit = Some(blit);
self.work = Some(work);
@@ -1144,6 +1148,7 @@ impl PostEffects {
main_depth: Option<&wgpu::TextureView>,
menu_radius: f32,
format: wgpu::TextureFormat,
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -1203,6 +1208,7 @@ impl PostEffects {
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect depth clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
@@ -1221,6 +1227,7 @@ impl PostEffects {
let c = t.clear.map(f64::from);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect target clear"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Post clear")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &chain.textures[i - 1].1,
resolve_target: None,
@@ -1274,6 +1281,7 @@ impl PostEffects {
&inputs,
(view(pass.output), sizes[pass.output]),
format,
timing.as_deref_mut(),
);
continue;
}
@@ -1287,6 +1295,7 @@ impl PostEffects {
(view(pass.output), sizes[pass.output]),
menu_radius,
format,
timing.as_deref_mut(),
);
}
}
@@ -1303,6 +1312,7 @@ impl PostEffects {
inputs: &[(&wgpu::TextureView, [u32; 2])],
output: (&wgpu::TextureView, [u32; 2]),
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
let Some(pipeline) = translated.pipelines.get(&format) else {
return;
@@ -1353,6 +1363,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1381,6 +1392,7 @@ impl PostEffects {
output: (&wgpu::TextureView, [u32; 2]),
menu_radius: f32,
format: wgpu::TextureFormat,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
// SamplerInfo: OutSize, then each input's size.
let info = [
@@ -1441,6 +1453,7 @@ impl PostEffects {
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect pass"),
timestamp_writes: timing.and_then(|t| t.writes("Post effect")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: output.0,
resolve_target: None,
@@ -1715,6 +1728,7 @@ mod tests {
menu_blur_radius: 5.0,
globals: Globals::default(),
},
None,
);
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
@@ -1845,6 +1859,7 @@ mod tests {
menu_blur_radius: 0.0,
globals: Globals::default(),
},
None,
));
let buffer = crate::gfx::copy_frame(device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
+3 -2
View File
@@ -341,9 +341,10 @@ impl Settings {
.parent()
.map(crate::extras::Extras::load)
.unwrap_or_default();
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
.map(crate::mod_list::ModList::load)
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
Ok(bytes) => Self {
@@ -650,7 +651,7 @@ impl Settings {
self.horizon.save(dir);
self.controller.save(dir);
self.extras.save(dir);
self.mods.save(dir);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
/// Volume of a sound source (master excluded).
@@ -25,6 +25,9 @@ pub const UI: usize = 10;
/// sources: `clamp((int) sqrt(255), 2, 8)` streaming, the rest static.
pub const STATIC_CHANNELS: usize = 247;
pub const STREAMING_CHANNELS: usize = 8;
/// Static channels only sounds played to the listener (no position) may take
/// while the `uiSoundReserve` extra is on.
pub const HEARD_RESERVE: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayResult {
@@ -792,6 +795,9 @@ pub struct SoundEngine {
pub source_volumes: [f32; 11],
/// A `SoundEventListener` is registered.
pub listening: bool,
/// Static channels positional sounds leave free for the ones played to the
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
pub listener_reserve: usize,
pub notifications: Vec<Notification>,
}
fn handle_channel(h: &Handle) -> Option<ChannelId> {
@@ -819,6 +825,7 @@ impl SoundEngine {
limits: [STATIC_CHANNELS, STREAMING_CHANNELS],
source_volumes: [1.0; 11],
listening: false,
listener_reserve: 0,
notifications: Vec::new(),
}
}
@@ -1100,7 +1107,15 @@ impl SoundEngine {
let looping = instance.looping && instance.delay <= 0;
let streaming = sound.stream;
let pool = usize::from(streaming);
let channel = if self.used[pool] >= self.limits[pool] {
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
// them (a crater's worth of blocks breaking) can't stop the ones played to the listener.
let positional = !instance.relative && instance.attenuation != Attenuation::None;
let reserved = if positional && !streaming {
self.listener_reserve
} else {
0
};
let channel = if self.used[pool] + reserved >= self.limits[pool] {
None
} else {
backend.create(streaming)
@@ -1549,4 +1564,47 @@ mod tests {
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
}
#[test]
fn world_sounds_leave_channels_for_the_listener_only_with_the_extra() {
use test_backend::Null;
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"block.stone.break": {"sounds": ["dig/stone1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
let random = || LegacyRandom::new(0);
let block = |i: usize| {
let at = [i as f64, 64.0, 0.0];
let source = crate::settings::sound_source("minecraft:block.stone.break");
SoundInstance::at(
"minecraft:block.stone.break",
source,
1.0,
1.0,
random(),
at,
)
};
let click = || SoundInstance::for_ui("minecraft:ui.button.click", 1.0, 0.25, random());
for reserve in [0, HEARD_RESERVE] {
let mut engine = SoundEngine::new(registry.clone());
engine.listener_reserve = reserve;
let mut backend = Null::default();
// Positional sounds take the static channels the reserve leaves; then their plays fail.
let started = (0..STATIC_CHANNELS)
.filter(|&i| {
engine.play_new(block(i), false, &mut backend).1 == PlayResult::Started
})
.count();
assert_eq!(started, STATIC_CHANNELS - reserve);
let heard = engine.play_new(click(), false, &mut backend).1;
assert_eq!(
heard == PlayResult::Started,
reserve > 0,
"reserve {reserve}"
);
}
}
}
@@ -149,6 +149,8 @@ pub trait LodSampler: Send {
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
self.column(x, z)
}
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
/// when sampling failed and should be tried again later.
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
let mut columns = Vec::with_capacity(side * side);
for dz in 0..side {
@@ -2371,6 +2373,7 @@ mod tests {
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
mesh.block_entities.push(([1, 2, 3], 7));
mesh.lights.push(([1, 2, 3], 7));
mesh.visibility = 0;
mesh.sides = crate::mesher::Sides::unsorted(16);
let capacities = mesh.capacity_bytes();
@@ -2387,6 +2390,7 @@ mod tests {
assert!(reused.solid.is_empty());
assert!(reused.translucent.is_empty());
assert!(reused.block_entities.is_empty());
assert!(reused.lights.is_empty());
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
assert_eq!(reused.sides, crate::mesher::Sides::default());
assert_eq!(pool.bytes, 0);
+80 -6
View File
@@ -92,19 +92,29 @@ fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
// `tiled`: a distant tile's face takes its sprite again per block, so its gradients span many
// sprites while `uv` stays inside one (see `tile_uv` below).
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
let at = (center + offset) * pixel;
if tiled {
// An anisotropic sampler lays its taps along those gradients, past the sprite's edge,
// where the clamped sampler reads that edge texel again: long streaks on the far tiles.
// Asking for the level the gradients give takes the one tap an anisotropy of 1 would.
let level = max(length(du), length(dv)) / min(pixel.x, pixel.y);
return sprite_level(at, layer, max(0.0, log2(level)));
}
return sprite_grad(at, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
texel_screen: vec2<f32>, tiled: bool) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
@@ -124,7 +134,7 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -137,12 +147,15 @@ fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
let du = dpdx(tiled);
let dv = dpdy(tiled);
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
return sample_rgss(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
struct TerrainOut {
@@ -295,6 +308,56 @@ fn fade_code(w: f32) -> u32 {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid terrain draws in two passes from the same draws: first the quads with no
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
}
fn left_out() -> TerrainOut {
var out: TerrainOut;
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
@vertex fn vs_terrain_opaque(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_cutout(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_flat_opaque(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
let origin = slot_origins[block_slots[vertex >> 8u]];
if !opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
@vertex fn vs_terrain_flat_cutout(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
let origin = slot_origins[block_slots[vertex >> 8u]];
if opaque_quad(layer, origin.w) { return left_out(); }
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
let pos = position + origin;
@@ -344,6 +407,13 @@ fn brightness(c: vec3<f32>) -> f32 {
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
return solid_finish(in, texel, color);
}
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
@@ -360,6 +430,10 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
// terrain.fsh: the fade-in mixes toward the fog colour.
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return solid_finish(in, texel, color);
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
// brightest. Only pixels as bright as their sprite's threshold may: its
+14 -7
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -103,20 +107,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -34,6 +34,8 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
@@ -41,6 +43,7 @@
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
@@ -48,6 +51,7 @@
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
@@ -12,6 +12,7 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -133,7 +134,7 @@ fn main() -> Result<()> {
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
properties: properties.into(),
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
.collect();
BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
}
}
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
+1 -1
View File
@@ -478,7 +478,7 @@ pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
inside
}
/// `Projectile.lerpRotation`.
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
pub fn lerp_rotation(mut old: f32, new: f32) -> f32 {
while new - old < -180.0 {
old -= 360.0;
}
@@ -445,6 +445,7 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
@@ -6,7 +6,19 @@ use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const PROTOCOL: &str = "2025-06-18";
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
}
fn tools() -> Vec<Value> {
commands::COMMANDS
@@ -134,7 +146,7 @@ impl Session {
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -234,9 +256,9 @@ mod tests {
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-03-26");
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
+20 -13
View File
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
}
}
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
pub fn collected_components(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
collected
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
for (component, value) in collected_components(saved) {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
+160
View File
@@ -0,0 +1,160 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
#![forbid(unsafe_code)]
extern crate self as horizon_client;
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
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,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod block_entity_item;
pub mod block_pattern;
pub mod block_transform;
pub mod boss_overlay;
pub mod cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+48 -1
View File
@@ -1356,10 +1356,11 @@ impl Recipes {
None => sources.push((name, bytes)),
}
}
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
for (name, bytes) in sources {
// Vanilla's RecipeManager logs a file that does not parse and
// goes on ("Couldn't parse data file").
let v: Value = match serde_json::from_slice(&bytes) {
let mut v: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
log::warn!("Horizon: recipe {name} not loaded: {e}");
@@ -1373,6 +1374,14 @@ 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) {
continue;
}
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
@@ -2163,6 +2172,44 @@ mod tests {
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
let _ = fs::remove_dir_all(&root);
}
/// A pack's recipe filter removes a recipe and swaps a mod recipe's input
/// in the loaded tables, after the packs are merged.
#[test]
fn recipe_filters_remove_and_swap() {
let root = std::env::temp_dir().join(format!("horizon-filters-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let crusher = |input: &str| {
format!(
r#"{{"type":"mymod:crusher","input":"{input}","result":{{"id":"mymod:dust"}}}}"#
)
.into_bytes()
};
fs::write(
root.join("data/minecraft/recipe/iron.json"),
crusher("minecraft:iron_ingot"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/gold.json"),
crusher("minecraft:gold_ingot"),
)
.unwrap();
let filter = br##"{"filters":[
{"id":"minecraft:gold","remove":true},
{"type":"mymod:*","replace_input":{"from":"minecraft:iron_ingot","to":"#c:ingots/iron"}}
]}"##;
let packs = [("recipe_filter/pack:f".to_owned(), vec![filter.to_vec()])];
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
let crushed = &recipes.custom["mymod:crusher"];
assert_eq!(
crushed.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["minecraft:iron"]
);
assert_eq!(crushed[0].json["input"], "#c:ingots/iron");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
+6 -4
View File
@@ -86,15 +86,17 @@ impl Displayer {
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
/// pass's median milliseconds, in the order they run).
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
labelled_pass_lines("Shader pack GPU", passes)
}
/// `label` and the passes' total, then the costliest passes by GPU time.
pub fn labelled_pass_lines(label: &str, passes: &[(String, f32)]) -> Vec<String> {
const SHOWN: usize = 8;
if passes.is_empty() {
return Vec::new();
}
let total: f32 = passes.iter().map(|p| p.1).sum();
let mut lines = vec![format!(
"Shader pack GPU: {total:.2} ms in {} passes",
passes.len()
)];
let mut lines = vec![format!("{label}: {total:.2} ms in {} passes", passes.len())];
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
lines.extend(
+107
View File
@@ -0,0 +1,107 @@
//! Startup shared by the gameplay library and its executable.
use crate::{crash, game, logging};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// 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);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
}
+252 -46
View File
@@ -154,6 +154,7 @@ mod lan_camera;
mod lan_chat;
mod lan_crafting;
mod lan_entities;
mod lan_far;
mod lan_mobs;
mod lan_mount_menu;
mod lan_particles;
@@ -187,6 +188,7 @@ mod moving_pistons;
mod music;
mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
mod native_components;
mod native_criteria;
@@ -196,6 +198,7 @@ mod native_falling;
mod native_hooks;
mod native_lifecycle;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
mod native_motion;
mod native_net;
@@ -481,7 +484,11 @@ impl Options {
self.player_name.clone().unwrap_or_else(|| {
std::env::current_dir().map_or_else(
|_| horizon_client::offline_name::generate(0),
|dir| horizon_client::offline_name::load_or_create(&dir),
|_| {
horizon_client::offline_name::load_or_create(
&horizon_client::logging::player_dir(),
)
},
)
})
}
@@ -607,7 +614,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -693,6 +700,8 @@ impl Options {
"--game-dir" | "--gameDir" => o.game_dir = Some(value.into()),
// main has made it the game home already (logging::set_game_home).
"--home" => {}
// ...and this player's own folder (logging::set_player_dir).
"--player-dir" => {}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
@@ -765,6 +774,9 @@ enum Screen {
Win,
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
DebugOptions,
/// A screen of a native mod's own (`native_mod_screen_open`): it draws and
/// takes its input through the screen hooks.
Mod,
}
impl Screen {
/// The id native mods see for this screen (`NativeScreenView.id`, the
@@ -791,6 +803,7 @@ impl Screen {
Screen::DialogWarning => "dialog_warning",
Screen::Win => "win",
Screen::DebugOptions => "debug_options",
Screen::Mod => "mod",
}
}
}
@@ -872,6 +885,13 @@ struct GameLevel<'w> {
dropped: &'w [ItemEntity],
orbs: &'w [ExperienceOrb],
projectiles: &'w [horizon_client::projectiles::Projectile],
/// The tridents, firework rockets and fishing hooks here. None ignores
/// block triggers (`Entity.isIgnoringBlockTriggers`), so each counts on a
/// plate or tripwire (`getEntitiesOfClass`). The flag says whether it also
/// reaches `entityInside` through this list (`applyEffectsFromBlocks`):
/// not a rocket boosting a player or a hook on an entity, whose ticks
/// skip that step, nor a trident, which `tick_blocks` sweeps itself.
trigger_projectiles: Vec<(Aabb, bool)>,
biomes: &'w Biomes,
seed: i64,
behaviours: &'w Behaviours,
@@ -1549,6 +1569,7 @@ impl LevelAccess for GameLevel<'_> {
};
let others = || {
self.other_trigger_boxes()
.chain(self.trigger_projectiles.iter().map(|&(bounds, _)| bounds))
.filter(|b| area.intersects(*b))
.count()
};
@@ -1649,6 +1670,8 @@ struct FallingBlock {
age: i32,
/// `Entity.isNoGravity`.
no_gravity: bool,
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
portal: horizon_client::nether_portal::PortalState,
/// `FallingBlockEntity.dropItem`.
drop_item: bool,
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
@@ -1739,8 +1762,8 @@ struct WorldRest {
/// recipes, loot tables and tags from the world's pack stack, over the
/// resource root's own. Worlds without packs use `Resources.rest` directly.
struct WorldTables {
/// The pack files the tables were built from, for the advancement tree
/// reload and the loot data.
/// The pack files the tables were built from (a native mod's among
/// them), for the advancement tree reload and the loot data.
files: Vec<(String, Vec<Vec<u8>>)>,
items: Items,
recipes: Recipes,
@@ -2217,6 +2240,8 @@ struct Game<'a> {
/// Minecraft.pause: a single-player screen that pauses the game is open.
pause: bool,
debug: bool,
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
gpu_passes: bool,
screen: Screen,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
@@ -2233,6 +2258,8 @@ struct Game<'a> {
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
@@ -2621,6 +2648,8 @@ struct Game<'a> {
native_lifecycle: native_lifecycle::NativeLifecycle,
/// Native mods' messages waiting (`game/native_net.rs`).
native_net: native_net::NativeNet,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// Meshed sections waiting for an upload budget (Horizon settings).
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
@@ -2813,6 +2842,8 @@ struct Game<'a> {
border: horizon_client::worldborder::WorldBorder,
/// `WorldBorderRenderer` and its render state.
border_renderer: horizon_client::border_render::BorderRenderer,
/// The camera's 16-block section when the wall was last invalidated.
border_section: Option<[i32; 3]>,
border_state: horizon_client::border_render::BorderRenderState,
/// `Hud.tabList`: the player list's header, footer and heart states.
tab_list: horizon_client::tab_list::TabList,
@@ -3435,6 +3466,9 @@ impl Game<'_> {
}
/// ClientLevel.addDestroyBlockEffect: a burst per outline box.
fn destroy_particles(&mut self, pos: [i32; 3], state: BlockStateId) {
if self.settings.extras.hide_block_particles {
return;
}
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
return;
};
@@ -3446,6 +3480,9 @@ impl Game<'_> {
}
/// ClientLevel.addBreakingParticles: one crumb on the face being mined.
fn crack_particle(&mut self, pos: [i32; 3], face: [i32; 3], state: BlockStateId) {
if self.settings.extras.hide_block_particles {
return;
}
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
return;
};
@@ -3569,6 +3606,10 @@ impl Game<'_> {
// Block.popResource: Mth.nextDouble offsets from the level random; the
// motion is `new ItemEntity(..)`'s own, from the item's random.
let p = spill::pop_position(&mut self.world.random, pos);
// Block.popResource draws its offsets before the private overload checks the gamerule.
if !self.ui.rules.bool("block_drops") {
return;
}
let v = dispenser::new_item_motion();
self.spawn_item(stack, p, v, 10);
}
@@ -3896,6 +3937,18 @@ impl Game<'_> {
.map(|g| g.position)
}))
.collect();
let mut trigger_projectiles: Vec<(Aabb, bool)> = self
.tridents
.iter()
.map(|t| (horizon_client::arrows::arrow_bounds(t.position), false))
.chain(self.rockets.iter().map(|r| (r.bounds(), !r.attached)))
.chain(
self.hook
.iter()
.map(|h| (h.bounds(), h.applies_block_effects())),
)
.collect();
self.lan_hook_triggers(&mut trigger_projectiles);
let blockers = Blockers {
mobs: &self.mobs,
armor_stands: &self.armor_stands,
@@ -3913,6 +3966,7 @@ impl Game<'_> {
dropped: &self.dropped,
orbs: &self.orbs,
projectiles: &self.creatures.projectiles,
trigger_projectiles,
world: &mut self.world,
facts: self.res.facts(),
collision: self.collision,
@@ -4284,7 +4338,16 @@ impl Game<'_> {
}
// The mobs and the other entities (items, orbs, projectiles...) too.
let mut touched: Vec<BlockPos> = Vec::new();
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
let projectiles = level
.trigger_projectiles
.iter()
.filter(|(_, applies)| *applies)
.map(|&(bounds, _)| bounds);
for bounds in level
.mob_trigger_boxes()
.chain(level.other_trigger_boxes())
.chain(projectiles)
{
let (lo, hi) = (bounds.min_position(), bounds.max_position());
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
@@ -4726,6 +4789,7 @@ impl Game<'_> {
velocity: Vec3::ZERO,
age: 0,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
@@ -4765,7 +4829,7 @@ impl Game<'_> {
}
PlayerEffect::InteractLoot(_) => {}
},
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80),
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
LevelEvent::BlockEvent {
pos,
state,
@@ -5071,7 +5135,7 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
if !f.no_gravity {
@@ -5117,30 +5181,11 @@ impl Game<'_> {
f.position.z + movement.z,
);
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
// FallingBlockEntity.tick: off the ground, it gives up after 600
// ticks (100 outside the world) and drops itself as an item.
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if next.y < f.position.y {
f.fall_distance += f.position.y - next.y;
}
let from = f.position;
f.position = next;
if on_ground {
let pos = [
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
];
return Ok(FallStep::Landed(pos));
}
// Entity.move stops it on the axes it collided on.
if movement.x != wanted.x {
f.velocity.x = 0.0;
@@ -5151,6 +5196,10 @@ impl Game<'_> {
if movement.z != wanted.z {
f.velocity.z = 0.0;
}
if on_ground {
self.falling_block_impact(f);
f.fall_distance = 0.0;
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
// drags it. Entity.move records the move unless it was blocked short
@@ -5182,12 +5231,44 @@ impl Game<'_> {
if stuck {
f.stuck = horizon_client::falling_block::web_multiplier();
}
// FallingBlockEntity.tick handles its portal after the move's block
// effects, before placing/dropping on the floor under an End portal.
let teleported = self.falling_through_portal(f);
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
// either way through the End deliberately keeps the source's final
// placement/drop branch, even though the source entity was removed.
if let Some(target) = teleported
&& self.dim != Dim::End
&& target != Dim::End
{
return Ok(FallStep::Gone);
}
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if on_ground {
return Ok(FallStep::Landed([
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
]));
}
f.velocity = Vec3::new(
f.velocity.x * 0.98,
f.velocity.y * 0.98,
f.velocity.z * 0.98,
);
Ok(FallStep::Falling)
Ok(if teleported.is_some() {
FallStep::Gone
} else {
FallStep::Falling
})
}
/// `Entity.spawnAtLocation`: an item entity where the block was.
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
@@ -5339,6 +5420,9 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
}
if creative && storage::is_shulker_box(&facts.name) {
self.drop_creative_shulker(pos);
}
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
// with its bees and honey level.
if creative
@@ -7707,7 +7791,12 @@ impl Game<'_> {
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
// moved to (x, y): the damage bar, the cooldown shade and the count.
let info = self.items().get(&stack.item);
let max_damage = info.map_or(0, |info| info.max_damage as i32);
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
info.map_or(0, |info| info.max_damage as i32)
} else {
0
};
let cooldown =
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
let partial = self.clock.fraction() as f32;
@@ -8086,6 +8175,7 @@ impl Game<'_> {
Screen::Dead => self.draw_death_screen(&mut batch),
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
@@ -8125,11 +8215,12 @@ impl Game<'_> {
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the open
/// screen, once a frame; the Win and Options screens call this on their
/// own path. Reads `self.screen`, the one `hud_layers` drew. Not while
/// playing with no screen. Client only, read-only: what a mod draws is
/// its own.
/// playing with no screen, nor while the server pack prompt hides the
/// open screen (`hud_layers` draws `Playing` then). Client only,
/// read-only: what a mod draws is its own.
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
let screen = self.screen;
if screen == Screen::Playing {
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
return;
}
// slot_count mirrors slots() without allocating; keep them in step.
@@ -10079,6 +10170,11 @@ impl Game<'_> {
pub(super) fn camera_orientation(&self) -> glam::Quat {
camera_rotation(self.camera_type, self.yaw, self.pitch)
}
/// The entity whose geometry starts at vertex `start` (0: none), for frame
/// generation to follow its own motion. Each run lasts until the next mark.
pub(super) fn mark_motion(&self, start: usize, id: u64) {
self.entity_motion.borrow_mut().push((start as u32, id));
}
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
fn entity_geometry(
&mut self,
@@ -10087,6 +10183,7 @@ impl Game<'_> {
mut out: Vec<Vertex>,
) -> Vec<Vertex> {
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
// LevelExtractor.extract: the transient blocks queued since the last frame.
self.drain_transient_block_queue();
self.prepare_entity_culling(camera);
@@ -10191,6 +10288,7 @@ impl Game<'_> {
// `ItemEntityRenderer`.
self.mark_entity(out.len(), "minecraft:item");
self.dropped_item_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
@@ -10203,6 +10301,7 @@ impl Game<'_> {
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
self.mob_geometry(camera, alpha, &mut out);
self.mark_motion(out.len(), 0);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
@@ -11362,6 +11461,9 @@ impl Game<'_> {
&self.options.resources,
&pack_files(&self.world_tables),
);
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
self.native_server_data_loaded(false);
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.prefetch_advancement_tree();
@@ -13228,6 +13330,8 @@ impl Game<'_> {
self.renderer.as_ref(),
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)
}
fn apply_settings(&mut self) {
@@ -14309,6 +14413,8 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
// Before any text of this frame: a full glyph region starts over.
self.gui.atlas.font.begin_frame();
self.glint_frame = self.capture_glint_frame();
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
@@ -14702,7 +14808,9 @@ impl Game<'_> {
self.bolt_instances(camera)
};
let mut additive = Vec::new();
if !title && self.dim == Dim::End {
// The dragon is drawn in every dimension, as any entity: a dragon
// summoned outside the End (the server's AddEntity) is shown too.
if !title {
let mut dragon = Vec::new();
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
@@ -14763,13 +14871,14 @@ impl Game<'_> {
let pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32)
self.shader_pack_inputs(camera, alpha as f32, &sky)
};
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let entity_motion = std::mem::take(self.entity_motion.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let frame = Frame {
@@ -14853,6 +14962,7 @@ impl Game<'_> {
color: sky.cloud_color,
}),
entities: &entities,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
@@ -14923,7 +15033,7 @@ impl Game<'_> {
};
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(self.debug);
renderer.set_pass_timing(self.debug || self.gpu_passes);
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
@@ -14931,6 +15041,7 @@ impl Game<'_> {
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.entity_motion.get_mut() = entity_motion;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
self.see_through_vertices = see_through;
@@ -15058,6 +15169,13 @@ impl Game<'_> {
distance,
&mut self.border_state,
);
// `LevelRenderer.repositionCamera`: the wall is rebuilt when the camera
// enters another `SectionPos` (it only holds the stretch in range).
let section = camera.map(|c| (c.floor() as i32) >> 4);
if self.border_section != Some(section) {
self.border_renderer.invalidate();
self.border_section = Some(section);
}
// Camera.depthFar, as the renderer's far plane.
let depth_far =
horizon_client::camera::depth_far(self.terrain_distance(), self.settings.cloud_range);
@@ -15137,6 +15255,7 @@ impl Game<'_> {
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
Screen::Win => self.close_win_screen()?,
Screen::DebugOptions => self.close_debug_options(),
Screen::Mod => self.close_mod_screen(),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -15353,11 +15472,25 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance and Far Detail Reduced as the player chose them.
/// LOD Distance and Far Detail Reduced as the player chose them, the
/// distance no farther than a server sampling far terrain for us allows.
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
(g.lod_distance, reduced)
let distance = match self.server_far_terrain() {
Some(offer) => g.lod_distance.min(offer.distance),
None => g.lod_distance,
};
(distance, reduced)
}
/// The far terrain another machine's server samples for us
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
/// its own server thread, which hands its generator over directly.
fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
if self.integrated.is_some() {
return None;
}
self.remote.as_ref()?.far_terrain
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
@@ -15378,6 +15511,11 @@ impl Game<'_> {
}
}
fn clear_lod(&mut self) {
// The samplers waiting on the server stop first: dropping the far
// terrain joins them.
if let Some(remote) = &self.remote {
remote.session.cancel_far_terrain();
}
self.lod = None;
self.lod_source_attempted = false;
self.lod_observe.clear();
@@ -15392,7 +15530,9 @@ impl Game<'_> {
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
&& (self.remote.is_none() || self.integrated.is_some());
&& (self.remote.is_none()
|| self.integrated.is_some()
|| self.server_far_terrain().is_some());
if !enabled {
if self.lod.is_some() || self.lod_source_attempted {
self.clear_lod();
@@ -15404,9 +15544,16 @@ impl Game<'_> {
}
self.lod_source_attempted = true;
let dim = self.dim;
let source = match &self.integrated {
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
None => Some(self.streaming.lod_source()),
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
(None, Some(offer), Some(remote)) => {
let states = self.res.facts().states.len() as u32;
let biomes = self.res.biomes().index.clone();
let source: Arc<dyn horizon_client::streaming::LodSource> =
remote.session.far_terrain(offer, biomes, states);
Some(source)
}
_ => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
self.lod = Some(horizon_client::lod::Lod::new(
@@ -15420,6 +15567,16 @@ impl Game<'_> {
if let Err(e) = self.poll_remote() {
log::warn!("Horizon: {e}");
}
// The renderer laid translucent quads out again (a shader pack's flat terrain came
// or went) and dropped every section and far tile: meshed again, as for a new one.
if self
.renderer
.as_mut()
.is_some_and(Renderer::take_dropped_sections)
{
self.clear_lod();
self.all_sections_changed();
}
self.prepare_lod();
if self.lod.is_some() {
for &((x, _, z), _, _) in &self.world.light_edits {
@@ -15738,6 +15895,30 @@ impl Game<'_> {
self.physical_buttons.remove(button);
}
}
let mouse = if !self.pad_synthetic_input()
&& let WindowEvent::MouseInput { state, button, .. } = &event
{
Some((
glfw_button(*button),
*state == ElementState::Pressed,
glfw_modifiers(self.modifiers),
))
} else {
None
};
if let Some((button, pressed, modifiers)) = mouse {
let mut canceled = false;
self.native_mouse_button_pre(button, pressed, modifiers, &mut canceled);
if canceled {
return;
}
}
self.handle_window_event_inner(event_loop, event);
if let Some((button, pressed, modifiers)) = mouse {
self.native_mouse_button_post(button, pressed, modifiers);
}
}
fn handle_window_event_inner(&mut self, event_loop: &dyn EventLoopCtl, mut event: WindowEvent) {
if let WindowEvent::Focused(false) = &event {
self.physical_keys.clear();
self.physical_buttons.clear();
@@ -17263,6 +17444,7 @@ impl<'a> Game<'a> {
was_playing: false,
pause: false,
debug: false,
gpu_passes: false,
screen: Screen::Playing,
window: None,
renderer: None,
@@ -17272,6 +17454,7 @@ impl<'a> Game<'a> {
see_through_vertices: Vec::new(),
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
@@ -17516,6 +17699,7 @@ impl<'a> Game<'a> {
native_shaders: Default::default(),
native_lifecycle: Default::default(),
native_net: Default::default(),
native_mod_screen: None,
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
@@ -17603,6 +17787,7 @@ impl<'a> Game<'a> {
brushing: Default::default(),
border: Default::default(),
border_renderer: Default::default(),
border_section: None,
border_state: Default::default(),
tab_list: Default::default(),
subtitles: Default::default(),
@@ -17925,11 +18110,16 @@ impl Game<'_> {
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
/// so it runs once per world open and on `/reload`.
fn build_world_tables(&self) -> Option<WorldTables> {
let files = functions::content_overlay(
// The ask of this load, both here and on `/reload`: a native mod
// recomputes the pack files it contributes every time.
let mut contributed = Vec::new();
self.native_data_pack_files(&mut contributed);
let files = functions::overlay_files(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
contributed,
);
if files.is_empty() {
return None;
@@ -17991,6 +18181,7 @@ impl Game<'_> {
&overlay,
));
self.adv.reset_tree();
self.native_server_data_loaded(true);
}
fn join_world_rest(&mut self) -> Result<()> {
@@ -18547,7 +18738,10 @@ fn load_world_rest(
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect();
let mob_data = horizon_client::mobs::MobData::load(root, facts);
let mut mob_data = horizon_client::mobs::MobData::load(root, facts);
mob_data.cem = std::sync::RwLock::new(Arc::new(
horizon_client::mobs::cem::CemModels::load(root, &mob_types),
));
horizon_client::mobs::trial_spawner::load_registry(root);
Ok((mob_types, mob_data))
};
@@ -18657,6 +18851,8 @@ fn build_renderer(
previous: Option<&Renderer>,
prepared: Option<horizon_client::gfx::PreparedTextures>,
api: horizon_client::graphics::GraphicsApi,
translucent_batching: bool,
shader_pack: bool,
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
@@ -18691,6 +18887,8 @@ fn build_renderer(
prepared,
previous,
api,
translucent_batching,
shader_pack,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -18752,6 +18950,9 @@ fn loading_renderer(
None,
previous,
api,
// It draws no terrain.
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -18820,6 +19021,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_motion: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
@@ -18873,7 +19075,7 @@ fn start_game<'a>(
let handoff = prepared.is_some();
let mut settings = match settings {
Some(settings) => settings,
None => Settings::load(&std::env::current_dir()?.join("options.txt")),
None => Settings::load(&Game::options_path()),
};
if let Some(d) = options.distance_override {
settings.render_distance = d;
@@ -19293,6 +19495,8 @@ impl Loading {
self.renderer.as_ref(),
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)?;
log::info!(
"Horizon: world renderer ready after {} ms",
@@ -19536,7 +19740,7 @@ pub fn run() -> Result<()> {
// the error.
if !options.offscreen && !synchronous_startup(&options) {
log::error!("Horizon: {error}");
let settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
let settings = Settings::load(&Game::options_path());
return no_resources::show(&vanilla_root, &settings);
}
return Err(error.into());
@@ -19547,13 +19751,14 @@ pub fn run() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
horizon_client::heartbeat::start();
if let Err(e) = horizon_client::multiplayer::add_official_server(&std::env::current_dir()?)
if let Err(e) =
horizon_client::multiplayer::add_official_server(&horizon_client::logging::player_dir())
{
log::warn!("could not add the official server to servers.dat: {e}");
}
}
mods::register_components(&vanilla_root)?;
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
let early_settings = Settings::load(&Game::options_path());
// Minecraft's constructor opens the narrator; a server has none.
if options.execution_role.is_client() {
narrator::start(&early_settings);
@@ -20108,6 +20313,7 @@ mod tests {
velocity: Vec3::ZERO,
age: 5,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
+36 -13
View File
@@ -4,7 +4,7 @@
use super::{Dim, Game};
use horizon_client::{
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
hud::HudBatch,
hud::{HudBatch, rgba},
items::ItemStack,
text::{self, Style, Text},
toasts::Slide,
@@ -326,13 +326,19 @@ impl Game<'_> {
return;
}
let data = self.options.resources.join("data");
// The world's packs' advancements go over the game's own.
let files = super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
// The world's packs' advancements go over the game's own, and a
// native mod's contributed files are already among them: the tables
// were built for this open (`build_world_tables`), so the hook is
// asked once, not again here.
let files = match &self.world_tables {
Some(tables) => tables.files.clone(),
None => super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
),
};
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
@@ -1821,11 +1827,7 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
};
let (key, color) = toast_label(frame);
// AdvancementToast.extractRenderState: a title of one line sits under
// the frame's label; a longer one is wrapped at 125, the label fading
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
@@ -1862,6 +1864,15 @@ impl Game<'_> {
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
}
}
/// The toast's frame label and its colour (`AdvancementToast`: -30465, pink,
/// for a challenge, else -256, yellow), packed as `hud::rgba` for the batch.
fn toast_label(frame: Frame) -> (&'static str, u32) {
match frame {
Frame::Task => ("advancements.toast.task", rgba(255, 255, 0, 255)),
Frame::Goal => ("advancements.toast.goal", rgba(255, 255, 0, 255)),
Frame::Challenge => ("advancements.toast.challenge", rgba(255, 136, 255, 255)),
}
}
/// `Advancement.name`: the bracketed title in the frame's colour, with the
/// description on hover; the id when there is no display.
pub(super) fn adv_name(a: &adv::Advancement) -> Text {
@@ -2059,6 +2070,18 @@ pub(super) fn text_of(v: &Value) -> Text {
mod tests {
use super::*;
#[test]
fn toast_labels_are_yellow_or_pink() {
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
for frame in [Frame::Task, Frame::Goal] {
assert_eq!(toast_label(frame).1.to_le_bytes(), [255, 255, 0, 255]);
}
assert_eq!(
toast_label(Frame::Challenge).1.to_le_bytes(),
[255, 136, 255, 255]
);
}
fn recipes(n: usize) -> Toast {
Toast::recipe(
(0..n)
+27 -2
View File
@@ -424,6 +424,7 @@ impl Game<'_> {
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -498,7 +499,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -528,8 +529,16 @@ impl Game<'_> {
})
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
fn split_profiles_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -727,7 +736,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
if self.title.is_some() || self.split_profiles_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1591,6 +1600,22 @@ impl Game<'_> {
self.synth_click(event_loop, x, y, button)?;
ok(json!({ "ok": true, "screen": self.screen_label() }))
}
// Splitscreen's profile page, over a joining player's title screen.
"type" if self.split_profiles_open() => {
let text = line
.trim_start()
.strip_prefix("type")
.unwrap_or("")
.strip_prefix(' ')
.unwrap_or("");
let page = &mut self.ui.options.split_profiles;
if text == "\\b" {
page.key(winit::keyboard::KeyCode::Backspace, None);
} else {
page.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
}
"type" if self.title.is_some() => {
// type [--field name|seed] [--replace] TEXT on the title screens.
let mut text = line
@@ -303,6 +303,7 @@ impl Game<'_> {
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
let mut shulker_pushes = Vec::new();
for &(pos, ticker) in &positions {
match ticker {
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
@@ -315,17 +316,19 @@ impl Game<'_> {
.tick(pos, &players, &mut anims.book_random);
}
Ticker::Shulker { facing } => {
// A window that simulates the world ticks its boxes on the
// server pass (`tick_block_entities`, which also applies
// `moveCollidedEntities`); a server's client only animates
// what it is sent. Either way the collision queries read
// the lids from `World::shulkers` (`sync_shulkers`).
// `ShulkerBoxBlockEntity.tick` runs on both sides: the
// client moves its own player, including with a menu open.
// The server ticks its boxes in `tick_block_entities`.
if self.remote.is_some() {
self.be_anims
let tick = self
.be_anims
.shulkers
.entry(pos)
.or_default()
.tick(pos, facing);
if let Some(push) = tick.push {
shulker_pushes.push((pos, facing, push));
}
}
}
Ticker::Skull { powered } => {
@@ -432,6 +435,10 @@ impl Game<'_> {
}
}
self.be_anims.present = positions;
if self.remote.is_some() {
self.sync_shulkers();
self.push_shulker_entities(shulker_pushes);
}
}
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
/// tick blocks, with or without a renderer. The conduits are looked for
@@ -152,6 +152,31 @@ impl Game<'_> {
Some(stack)
}
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
/// one item at the block center, with collectComponents and the default pickup delay.
/// This direct spawn intentionally does not use the block_drops gamerule.
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
if items.iter().flatten().any(|s| s.count > 0));
if !filled {
return;
}
let Some(saved) = self.saved_block_entity(key) else {
return;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
let patch = block_entity_item::collected_components(&saved);
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
self.spawn_item(
stack,
pos.map(|v| f64::from(v) + 0.5),
super::dispenser::new_item_motion(),
10,
);
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
+4 -1
View File
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
properties.insert(k.to_owned(), v.to_owned());
}
Ok(BlockState { name, properties })
Ok(BlockState {
name,
properties: properties.into(),
})
}
impl Game<'_> {
+27 -1
View File
@@ -1119,7 +1119,7 @@ impl Game<'_> {
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
fluid = Some((fluid_hit_point(p, y, h), t));
break;
}
t += 0.05;
@@ -1454,10 +1454,36 @@ impl Game<'_> {
}
}
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
/// (the same fluid above it) is a full cube, so the hit is the ray's own
/// point and a boat placed under water stays at the eye; a partial cell is
/// clipped to its surface.
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
if h >= 1.0 {
p
} else {
[p[0], f64::from(y) + f64::from(h), p[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
/// cell is clipped to its surface.
#[test]
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
let eye = [0.5, 3.62, 0.5];
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
assert_eq!(
fluid_hit_point(eye, 3, 8.0 / 9.0),
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
);
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
+1 -3
View File
@@ -1421,9 +1421,7 @@ impl Game<'_> {
}
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
pub(super) fn take_screenshot(&mut self) {
let dir = std::env::current_dir()
.unwrap_or_default()
.join("screenshots");
let dir = horizon_client::logging::player_dir().join("screenshots");
if std::fs::create_dir_all(&dir).is_err() {
return;
}
+6
View File
@@ -94,6 +94,12 @@ pub(super) struct CombatTracker {
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
/// its entity id and display name, the blast's causing entity.
pub(super) blast_owner: Option<(u32, Text)>,
/// The guest behind `blast_owner`, when that player is a LAN guest; the
/// host's own blast leaves it None.
pub(super) blast_guest: Option<u32>,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
@@ -6,7 +6,9 @@
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
//! waits until the terrain and entities around the player are loaded;
//! `portal` sends the player through the nether portal it stands in.
//! `break` destroys a block as the server player; `portal` sends the player
//! through the nether portal it stands in; `falling` counts physical falling
//! blocks by dimension, block and gravity.
use super::Game;
use horizon_client::{Result, text::Text};
use serde_json::{Value, json};
@@ -244,7 +246,36 @@ impl Game<'_> {
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"break" => {
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
self.harness_owner(|g| g.break_block(pos))?;
}
"portal" => self.harness_portal()?,
"falling" => {
// Physical falling blocks are not command-selector targets
// yet. Inspect the actual level rather than that unrelated gap.
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
let block = op["block"].as_str().unwrap_or("minecraft:sand");
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
let back = self.dim;
self.enter_level(None, super::Dim::from_id(dimension))?;
let facts = self.res.facts();
let matches = |f: &super::FallingBlock| {
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
};
let count = self.falling.iter().filter(|f| matches(f)).count();
if op["discard"].as_bool() == Some(true) {
self.falling.retain(|f| !matches(f));
}
self.enter_level(None, back)?;
let record = json!({
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
"messages": [],
"results": [[true, count]],
});
out.push_str(&record.to_string());
out.push('\n');
}
other => return Err(format!("command harness: unknown op {other}").into()),
}
}
+20 -3
View File
@@ -2459,6 +2459,11 @@ impl Game<'_> {
ctx.has("keep"),
);
let mut count = 0;
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
// of states, and matching a tag is a name lookup.
let mut matched: std::collections::HashMap<BlockStateId, bool> =
std::collections::HashMap::new();
let plain = !(destroy || hollow || outline);
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
for x in min[0]..=max[0] {
@@ -2466,11 +2471,23 @@ impl Game<'_> {
if keep && current != air {
continue;
}
if let Some(f) = &filter
&& !self.block_matches(f, current)
{
// Already the block it would become: nothing to place (place_state would do nothing either).
if plain && current == state {
continue;
}
if let Some(f) = &filter {
let ok = match matched.get(&current) {
Some(&ok) => ok,
None => {
let ok = self.block_matches(f, current);
matched.insert(current, ok);
ok
}
};
if !ok {
continue;
}
}
let edge = x == min[0]
|| x == max[0]
|| y == min[1]
+31 -3
View File
@@ -109,10 +109,28 @@ impl Game<'_> {
let _ = flight.reply.send(summary.to_string());
}
}
/// The frame summary, with Horizon's own passes' median GPU times while they are timed.
fn summary_with_passes(&self) -> Value {
let mut summary = self.frame_stats.summary();
let passes = self
.renderer
.as_ref()
.map(|r| r.own_pass_times())
.unwrap_or_default();
if !passes.is_empty() {
summary["gpu_passes"] = Value::Object(
passes
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect(),
);
}
summary
}
pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) {
if let Some((deadline, reply)) = self.pending_bench.take() {
if Instant::now() >= deadline {
let _ = reply.send(self.frame_stats.summary().to_string());
let _ = reply.send(self.summary_with_passes().to_string());
} else {
self.pending_bench = Some((deadline, reply));
}
@@ -312,6 +330,15 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"gpu-passes" => {
match words.get(1).copied() {
None => {}
Some("on") => self.gpu_passes = true,
Some("off") => self.gpu_passes = false,
Some(other) => return Err(format!("unknown gpu pass mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
}
"section-sides" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
@@ -820,7 +847,7 @@ impl Game<'_> {
})))
}
"stats" => {
let summary = self.frame_stats.summary();
let summary = self.summary_with_passes();
if words.get(1) == Some(&"reset") {
self.frame_stats.reset();
}
@@ -1032,6 +1059,7 @@ impl Game<'_> {
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
Screen::Mod => "mod",
}
}
@@ -1144,7 +1172,7 @@ impl Game<'_> {
"distance": self.settings.horizon.lod_distance,
"effective_distance": self.lod_distance(),
"terrain_distance": self.terrain_distance(),
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else { "local" },
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
+33 -1
View File
@@ -2382,6 +2382,16 @@ impl Game<'_> {
self.particles.add(&options, at, velocity);
}
}
MobEvent::ParticleColored {
at,
color: [r, g, b],
} => {
let options = horizon_client::particles::ParticleOptions::colored(
horizon_client::particles::ParticleType::EntityEffect,
[r, g, b, 1.0],
);
self.particles.add(&options, at, [0.0; 3]);
}
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer { mob } => {
let previous = self
@@ -3788,7 +3798,7 @@ impl Game<'_> {
}
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false);
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
@@ -5901,6 +5911,7 @@ impl Game<'_> {
self.rendered_mobs.set(0);
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
@@ -5974,6 +5985,25 @@ impl Game<'_> {
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
if !is_player
&& !layers_only
&& self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
{
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
@@ -6075,6 +6105,8 @@ impl Game<'_> {
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
@@ -162,6 +162,13 @@ impl Game<'_> {
if self.debug && !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
// Horizon's own passes, in a group of their own.
let own_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
overlay::labelled_pass_lines("Horizon GPU", &r.own_pass_times())
});
if self.debug && !own_lines.is_empty() {
d.group("Horizon passes", own_lines);
}
}
"tps" => self.debug_tps_line(d),
// DebugEntryChunkGeneration, where the world is generated
@@ -877,6 +877,9 @@ impl Game<'_> {
.raw
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
lod_distance: horizon_client::lod_net::distance_property(
props.raw.get(horizon_client::lod_net::PROPERTY),
),
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
@@ -6,6 +6,7 @@
use super::{ServerState, log};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -48,6 +49,27 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
"serversettings/difficulty" => {
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
"serversettings/difficulty/set" => {
let Some(value) = body["value"].as_str() else {
return json!({ "error": "difficulty must be a string" });
};
let Some(index) = DIFFICULTIES.iter().position(|name| *name == value) else {
return json!({ "error": format!("unknown difficulty {value:?}") });
};
rpc(format!(
"Update difficulty from '{}' to '{}'",
DIFFICULTIES[self.level.difficulty_id() as usize],
value
));
// DedicatedServer.setDifficulty stores the property and then forces the world
// difficulty, including hardcore's always-hard rule and the player broadcast.
self.store_property("difficulty", value.to_owned());
self.set_difficulty_forced(if self.level.hardcore { 3 } else { index as u8 });
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
}
"allowlist/add" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Add player '{}' to allowlist", shown(&user)));
+2 -2
View File
@@ -1377,7 +1377,7 @@ impl Game<'_> {
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
self.spawn_primed_tnt(target_pos, false);
self.spawn_primed_tnt(target_pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
self.process_level_events()?;
} else {
@@ -1441,7 +1441,7 @@ impl Game<'_> {
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
let target = Self::pos_of(front);
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
self.add_primed_tnt(target, rot, 80);
self.add_primed_tnt(target, rot, 80, None, None);
self.audio.play(
"minecraft:entity.tnt.primed",
Some([
+53 -2
View File
@@ -438,8 +438,10 @@ impl Dragon {
fn sample(&self, delay: usize) -> (f64, f32) {
self.history[(self.head + 64 - (delay & 63)) & 63]
}
/// DragonFlightHistory.get(delay, partial).
/// `DragonFlightHistory.get(delay, partial)`, with
/// `EnderDragonRenderer.extractRenderState`'s frozen history on death.
fn sample_lerp(&self, delay: usize, partial: f32) -> (f64, f32) {
let partial = if self.alive() { partial } else { 0.0 };
let a = self.sample(delay);
let b = self.sample(delay + 1);
let yaw = b.1 + wrap(a.1 - b.1) * partial;
@@ -3874,7 +3876,7 @@ fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out:
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: 2,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
@@ -3911,6 +3913,55 @@ fn push_seen_with_hurt(
mod tests {
use super::*;
#[test]
fn dying_dragons_keep_their_flight_pose_between_frames() {
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
dragon.history.fill((70.0, 170.0));
dragon.position.y = 90.0;
dragon.yaw = -170.0;
dragon.record();
assert_eq!(dragon.sample_lerp(0, 0.5), (80.0, 180.0));
dragon.health = 0.0;
for partial in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(dragon.sample_lerp(0, partial), (70.0, 170.0));
}
}
/// Driven by `tools/test_dragon_render_parity.py`; compares the actual
/// ray vertices with EnderDragonRenderer's captured VertexConsumer output.
#[test]
#[ignore]
fn dragon_render_parity_fixture() {
let path = std::env::var("HORIZON_DRAGON_RENDER_OUTPUT").expect("harness output path");
let mut cases = Vec::new();
for t in [0.0, 0.05, 0.25, 0.5, 0.8, 0.9, 0.95, 1.0] {
let mut vertices = Vec::new();
dragon_rays(
glam::Mat4::IDENTITY,
t,
horizon_client::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
&mut vertices,
);
// The fourth quad vertex only pads a triangle for our index buffer.
let triangles: Vec<_> = vertices
.as_chunks::<4>()
.0
.iter()
.flat_map(|q| &q[..3])
.map(|v| serde_json::json!([v.pos[0], v.pos[1], v.pos[2], v.color]))
.collect();
cases.push(triangles);
}
std::fs::write(path, serde_json::to_vec(&cases).unwrap()).unwrap();
}
#[test]
fn death_rays_sample_inside_the_white_sprite() {
// A four-texel white sprite surrounded by transparent atlas pixels.
+25 -8
View File
@@ -4,7 +4,7 @@
//! merged drops and TNT chain priming, and optional fire.
use super::Game;
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
};
use horizon_core::{
geometry::{Vec3, VoxelShape},
@@ -175,12 +175,17 @@ pub(super) fn client_float() -> f32 {
impl Game<'_> {
/// `blast_cause` for the blast going off now.
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
blast_cause(
self.combat.explosion_owner,
self.combat.explosion_source,
|id| self.mob_display_name(id),
&self.combat.exploded,
)
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
// blast before any mob does, which makes the damage type
// `minecraft:player_explosion` (`server_explode` asks this).
self.combat.blast_owner.clone().or_else(|| {
blast_cause(
self.combat.explosion_owner,
self.combat.explosion_source,
|id| self.mob_display_name(id),
&self.combat.exploded,
)
})
}
/// `ServerExplosion.getSeenPercent` for an entity box.
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
@@ -976,7 +981,19 @@ impl Game<'_> {
let r = &mut self.world.random;
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
self.add_primed_tnt(BlockPos::new(pos[0], pos[1], pos[2]), rot, fuse);
// TntBlock.wasExploded hands the new TNT the blast's indirect
// source, so a chain reaction keeps the player who started it.
let (owner, guest) = match &self.combat.blast_owner {
Some(_) => (Some(Who::Player), self.combat.blast_guest),
None => (None, None),
};
self.add_primed_tnt(
BlockPos::new(pos[0], pos[1], pos[2]),
rot,
fuse,
owner,
guest,
);
}
Ok(())
}
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff. Show more