Compare commits

...
Author SHA1 Message Date
Ludovic Marie 97b19c8273 Merge remote-tracking branch 'origin/main' into perf/mesh-view-first 2026-11-02 08:11:57 +01:00
Ludovic MarieandClaude Opus 5.5 e21640123d Test: sections finish in any order across the pool's workers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:07:16 +01:00
Ludovic Marie 7e6cbea1a3 Merge pull request #6851 from horizon-rust/perf/entities-summon-stall
🤖 Agent: a call timeout now logs the server's state and no longer reads as a stop; the stall didn't reproduce (27k summons, slowest 0.32 s); fmt and clippy clean.
2026-11-02 08:06:28 +01:00
Ludovic MarieandClaude Opus 5.5 5b0fd8d4f5 Tell a busy server thread from a stopped one
IntegratedServer::call waits 10 s for the server thread's answer, and both
a stopped server and a slow one gave "the integrated server stopped". A
perf_bench run failed with it in the middle of 2000 summons and the log
could not say which. A timeout now logs a warning with the server's state
(running, autosaving, smoothed tick time), and the callers' error says the
server stopped or did not answer within 10 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:04:30 +01:00
Ludovic MarieandClaude Opus 5.5 afa453b431 Rank by section: a seen column's sections out of view wait like the columns behind
A column was picked whole, so its sections above and below the view (and, in the next batch,
the seen sections of farther columns) waited behind the ones under ground and in the sky.
Ready columns now split into the sections the camera sees and the rest; only the sections
taken leave dirty_sections (DirtySections::take). An update's mesh jobs are queued under one
lock, so a worker takes them in priority order rather than in the order they were made.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:02:44 +01:00
Ludovic Marie b2258cbf68 Merge pull request #6745 from horizon-rust/perf/recipes-read-once-2
🤖 Agent: verify and launcher green on this exact head; recipe/loot harnesses 8/8 on playhorizon. One risk checked: loot::load_dir now reads a whole folder before parsing; json_folder takes no lock during the read, and server.open_world did not slow in interleaved runs.
2026-11-02 08:00:30 +01:00
Ludovic MarieandClaude Opus 5.5 b37404afd8 Test: the world's columns are 24 sections; check sections in view against columns behind
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:59:25 +01:00
Ludovic Marie aa60eb76ea Merge pull request #6528 from Detimes/feat-player-totem-hook
🤖 Agent: queued at 821b9c45e. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:15 +01:00
Ludovic Marie a261703d72 Merge pull request #6653 from Detimes/feat-tnt-hook
🤖 Agent: queued at 42b2cd1d3. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:10 +01:00
Ludovic Marie 448899aa04 Merge pull request #6635 from Detimes/feat-protection-hooks
🤖 Agent: queued at 7b0c5e3bf. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:07 +01:00
Ludovic Marie 12ce2752a0 Merge pull request #6624 from Detimes/batch-misc
🤖 Agent: queued at 91acd7626. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:03 +01:00
Ludovic Marie bbdffc6584 Merge pull request #6623 from Detimes/feat-players-connection-hooks
🤖 Agent: queued at 532339112. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:46 +01:00
Ludovic Marie 29aebd155c Merge pull request #6547 from Detimes/feat-player-teleported-hook
🤖 Agent: queued at 59823928d. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:42 +01:00
Ludovic Marie 3c37ecbaba Merge pull request #6521 from Detimes/feat-food-level-hook
🤖 Agent: queued at 6d9f9189a. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:38 +01:00
Ludovic Marie 3354e5a9d5 Merge pull request #6520 from Detimes/feat-player-swap-hands-hook
🤖 Agent: queued at 509a5e82c. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 7ed902b518 Test: stop once the inner columns are meshed (the edge ones never are)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:50:32 +01:00
Ludovic MarieandClaude Opus 5.5 821b9c45e5 Mod API entries and dispatch calls for #6528's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:50:04 +01:00
Ludovic Marie ba923b393d Merge #6528 into the hook stack
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 07:49:54 +01:00
Ludovic MarieandClaude Opus 5.5 42b2cd1d3f Mod API entries and dispatch calls for #6653's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:42:13 +01:00
Ludovic Marie e78c83330a Merge #6653 into the hook stack
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/mod_api/generated.rs
#	docs/native-mods.md
2026-11-02 07:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 7b0c5e3bf8 Mod API entries and dispatch calls for #6635's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:42:01 +01:00
Ludovic Marie cd73a264c2 Merge #6635 into the hook stack
# Conflicts:
#	docs/native-mods.md
2026-11-02 07:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 91acd76265 Mod API entries and dispatch calls for #6624's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:54 +01:00
Ludovic MarieandClaude Opus 5.5 532339112f Mod API entries and dispatch calls for #6623's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:46 +01:00
Ludovic Marie 2bc228cfd0 Merge #6624 into the hook stack 2026-11-02 07:41:46 +01:00
Ludovic Marie ccda6631b3 Merge #6623 into the hook stack
# Conflicts:
#	docs/native-mods.md
2026-11-02 07:41:37 +01:00
Ludovic MarieandClaude Opus 5.5 59823928da Mod API entries and dispatch calls for #6547's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:36 +01:00
Ludovic Marie e3274bed46 Merge #6547 into the hook stack 2026-11-02 07:41:30 +01:00
Ludovic Marie 4c908814b1 Merge pull request #6818 from horizon-rust/fix/windres-space-path
Native mod builds: no space in the Windows build folder
2026-11-02 07:41:28 +01:00
Ludovic Marie 6d9f9189a7 Merge #6521 into the hook stack 2026-11-02 07:41:23 +01:00
Ludovic Marie 509a5e82c5 Merge #6520 into the hook stack 2026-11-02 07:41:16 +01:00
DetimesandClaude Sonnet 5.5 63eaabce49 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:40:05 +00:00
Detimes da0403f293 Merge remote-tracking branch 'upstream/main' into feat-player-totem-hook 2026-11-02 06:39:39 +00:00
Ludovic Marie c9343d6d72 Merge pull request #6395 from martipops/split/fresh-basic-setup
🤖 Agent: queued at afe39ff13 (Mac: clippy on main + 9 PRs, Python tests).
2026-11-02 07:39:09 +01:00
DetimesandClaude Sonnet 5.5 13539c30a7 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:35:05 +00:00
Detimes 4c88a971c4 Merge remote-tracking branch 'upstream/main' into feat-food-level-hook 2026-11-02 06:34:39 +00:00
Ludovic Marie 5197790e58 Merge pull request #6802 from horizon-rust/perf/mesh-review-nits
🤖 Agent: queued at 6ce752baa8: verify green, pr_ready green (render tests single-threaded on llvmpipe). One point worth a look: Chunk::light now goes through section_at, whose Err carries the out-of-column light (0 below, 0xF0 above), and the new padded_fill test checks every padded cell against Chunk::get/light for those edges.
2026-11-02 07:31:16 +01:00
DetimesandClaude Sonnet 5.5 838324f811 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:30:15 +00:00
Detimes 18dba0563a Merge remote-tracking branch 'upstream/main' into feat-player-swap-hands-hook 2026-11-02 06:29:50 +00:00
Ludovic Marie 848a87b731 Merge pull request #6817 from horizon-rust/perf/bench-gpu-passes
🤖 Agent: ready. The benchmark's 2 s wait before each flight now times the render passes, and the timing is off again before the flight, so FPS and gpu_p50 stay comparable with older reports. Metal is skipped because its passes overlap. pr_ready green on playhorizon (1377 client-lib tests); e2e report checked on an M4 Pro.
2026-11-02 07:27:02 +01:00
DetimesandClaude Sonnet 5.5 8b895aca0f Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:26:14 +00:00
Ludovic MarieandClaude Opus 5.5 4ed42694bc Native mod builds: no space in the Windows build folder
A Windows user name with a space (C:\Users\Jane Doe\AppData\Roaming)
broke every native mod build: windres passes its include folders to the
preprocessor unquoted, which splits them at the space, and
horizon-client's build script panicked on the failed resource compile.

- launcher: build_dir moves a path with a space to the ProgramData build
  folder, as it already does for a non-ASCII one (#5189, #6009).
- horizon-client build.rs: outside CI, a failed icon embed is a warning,
  so players on a launcher without the fix can still build. Release
  builds run in CI, where the icon stays required.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:25:40 +01:00
Ludovic Marie d8845075d2 Benchmark report: GPU time per render pass before each flight 2026-11-02 07:24:48 +01:00
Detimes 03df981326 Merge remote-tracking branch 'upstream/main' into feat-tnt-hook 2026-11-02 06:24:29 +00:00
levzzz 676746bce5 Merge pull request #6253 from arismoko/v2/ses_01M6C18WAJKS3HWAKR2EKX5REX-u9b
Mod API: trait entries for pending hook fragments, item-use and menu slot-rule hooks
2026-11-02 11:22:57 +05:00
Ludovic Marie a7df9ec019 Merge pull request #6808 from horizon-rust/perf/entities-stress
🤖 Agent: stress scenes for perf_bench (Horizon and vanilla); Horizon beats vanilla x2.2 (armor stands) to x10.8 (signs) on the M4 Pro; Python tests 607 ok, the bench mod compiles against 26.3.
2026-11-02 07:15:49 +01:00
Ludovic Marie 76aa871c1e Merge remote-tracking branch 'origin/main' into perf/entities-stress
# Conflicts:
#	mods/horizon/src/bench/java/dev/horizonlab/bench/VanillaBench.java
#	tools/perf_bench.py
2026-11-02 07:10:57 +01:00
Ludovic MarieandClaude Opus 5.5 2d8126fbca Mesh the sections the camera sees first, as vanilla compiles only those
Vanilla's LevelExtractor.extract schedules compiles for LevelRenderer.visibleSections only,
and its compile queue (SectionRenderDispatcher.isQueueEmpty) is what perf_bench's vanilla
driver waits for. Horizon meshed every section in the view distance nearest first, so after
a /tp the view waited behind the sections behind the camera.

- Streaming takes the camera's frustum and position (CameraView) each frame; ready columns
  it sees are picked first and their sections queue ahead (OUT_OF_VIEW bias).
- status.streaming.meshing_in_view counts the sections in view still to mesh; perf_bench
  waits for those instead of every section when a build reports it, like vanilla's driver.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:09:41 +01:00
DetimesandClaude Opus 5.5 1e3059a184 Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:56:39 +00:00
DetimesandClaude Opus 5.5 1adabb388f Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:53:42 +00:00
DetimesandClaude Opus 5.5 b9829e9cfa Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:50:44 +00:00
DetimesandClaude Opus 5.5 5a50067056 Fragment: the totem hook only listens, so no NeoForge counterpart
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:49:44 +00:00
DetimesandClaude Opus 5.5 f567c765e9 Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:45:26 +00:00
Detimes 2dc81db24b Merge remote-tracking branch 'upstream/main' into feat-tnt-hook 2026-11-02 05:44:37 +00:00
Detimes 2e037e4cb6 Merge remote-tracking branch 'upstream/main' into feat-protection-hooks 2026-11-02 05:44:36 +00:00
Detimes 2d6f2722d8 Merge remote-tracking branch 'upstream/main' into batch-misc 2026-11-02 05:44:36 +00:00
Detimes 005824bd0d Merge remote-tracking branch 'upstream/main' into feat-players-connection-hooks 2026-11-02 05:44:35 +00:00
Ludovic Marie 6ce752baa8 Merge remote-tracking branch 'origin/main' into perf/mesh-review-nits 2026-11-02 06:43:21 +01:00
Ludovic Marie 3a264943c7 Merge pull request #6672 from horizon-rust/perf/mesh-tint-uniform
Mesher: one-biome sections skip the tint blend
2026-11-02 06:43:12 +01:00
Detimes 6e3d4215c7 Merge remote-tracking branch 'upstream/main' into feat-player-teleported-hook 2026-11-02 05:40:58 +00:00
Detimes 0e873b88ff Merge remote-tracking branch 'upstream/main' into feat-food-level-hook 2026-11-02 05:40:57 +00:00
Detimes 8f7abd9070 Merge remote-tracking branch 'upstream/main' into feat-player-totem-hook 2026-11-02 05:40:57 +00:00
Detimes 519ce906cf Merge remote-tracking branch 'upstream/main' into feat-player-swap-hands-hook 2026-11-02 05:40:56 +00:00
Ludovic Marie 5afa1de5f7 Merge pull request #6744 from horizon-rust/perf/gen-coordinator
🤖 Agent: perf-gen-light verified this head: pr_ready --quick green, chunk_generation/decoration/server_world_gen/worldgen pass on playhorizon, generation order unchanged (differential test); coordinator CPU -30% at RD 32.
2026-11-02 06:21:15 +01:00
Ludovic Marie 40e044da77 Merge pull request #6757 from horizon-rust/perf/entity-gpu-skin
🤖 Agent: mobs posed on the GPU, byte-identical to the CPU path for every mob type; stress_mobs frame CPU 4.52 → 3.26 ms; verify passed.
2026-11-02 06:19:04 +01:00
Ludovic Marie e7275ddee3 Merge pull request #6756 from horizon-rust/perf/entity-skin-pass
🤖 Agent: renderer half of GPU posing, unused until #6757; shader output byte-identical to the CPU reference on a real adapter; verify passed.
2026-11-02 06:09:56 +01:00
Ludovic Marie f7b73ed5f6 Merge pull request #6755 from horizon-rust/perf/remote-mob-index
🤖 Agent: mob lookups by id go through a validated id → slot map; 3000 mobs: 5.5 ms → 0.0125 ms per tick of searching; verify passed.
2026-11-02 06:09:52 +01:00
Ludovic Marie 1c2ed6b613 Merge pull request #6535 from horizon-rust/perf/mesh-faces
Mesher: 15% less CPU per section again (face shortcut, bit-row VisGraph), identical meshes
2026-11-02 06:02:47 +01:00
Ludovic Marie c2d352e0a3 Merge pull request #6619 from horizon-rust/perf/bench-server-join
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green, CI verify green, and the Mac runs in the body used this branch end to end (Horizon, vanilla and vanilla-mods clients on a local Horizon server and on a vanilla 26.3 server.jar, with and without 100 bots). Tooling only (skip-release). Merging.
2026-11-02 05:48:50 +01:00
Ludovic Marie 64c0291da2 Merge pull request #6436 from horizon-rust/perf/net-replay-bench
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green (horizon-bots + horizon-protocol 173 tests), CI verify and parity green. Tooling only (skip-release). Merging.
2026-11-02 05:46:49 +01:00
Ludovic Marie 4f1ebf7deb Merge pull request #6754 from horizon-rust/fix/item-entity-render-distance
🤖 Agent: dropped items now use vanilla's 64 × 0.25-box × view-scale range (32 blocks at RD 16), not 64 blocks; verify passed.
2026-11-02 05:46:46 +01:00
Ludovic Marie f4ee603baf Merge remote-tracking branch 'origin/main' into perf/entities-stress 2026-11-02 05:46:29 +01:00
Ludovic Marie daf749bbcd Merge branch 'perf/entity-skin-pass' into perf/entity-gpu-skin 2026-11-02 05:45:12 +01:00
Ludovic MarieandClaude Opus 5.5 27877c639e Renderer: the pack uniforms test frame sends no posed entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:55 +01:00
Ludovic Marie dbb78b8a4c Merge remote-tracking branch 'origin/main' into perf/entity-skin-pass 2026-11-02 05:44:40 +01:00
Ludovic MarieandClaude Opus 5.5 c4f9b6fc7f tests: the remote motion fixture looks mobs up through MobIndex
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:22 +01:00
arismoko 121c848cff Docs: native mod trait hooks
The trait covers every hook in the catalog and the pending fragments, and "Adding a hook" says how a new one gets its trait method: its mod_api entry, the placeholder the generator fills, and the free-function hooks wired through installed callbacks.
2026-11-02 04:30:39 +00:00
arismoko c73dbb7b7d xtask mod-api fills new hooks' dispatch placeholders
A new `Game` hook can start with the placeholder body
`let _ = (every, parameter);`. Given its mod_api entry,
`cargo run -p xtask -- mod-api` replaces the placeholder with the
`mod_api::dispatch_<hook>` call, laid out as `cargo fmt` would; `--check`
reports it instead. A body that is neither the placeholder nor already
calls the dispatch is an error, named and never changed. Free-function
hooks stay wired by hand. Generated code is formatted for the
workspace's edition.
2026-11-02 04:30:29 +00:00
arismoko 0cc0fd1510 Mod API: client-library hooks through NativeClientHooks
`menu_slot_rules`, `living_breathe` and `ingredient` are free functions in
horizon-client-lib, which can't see the mod list. One `NativeClientHooks`
struct of callbacks, installed once from the executable's entry point as
horizon-world's `NativeLoadHooks` is, carries the three to their dispatch.
The generator resolves `horizon_client_lib::` targets (and the older
`horizon_client::menu` and `breathing` names) to the client source.

With these, every hook has a mod_api entry, and the check now fails
naming any hook without one.
2026-11-02 04:29:44 +00:00
arismoko 8d9a75e8ef Mod API: trait methods for every hook
Every hook in native-hooks.json and the pending fragments gets a mod_api
contract, so trait mods can answer each through `Hooks`. The generator
reads the fragments too, rejects duplicate names once, and the game's
hook bodies call their dispatch in one short form. Event types the
generated trait names are re-exported from `mod_api`, and horizon-world's
`NativeLoadHooks` gains loot functions and conditions.
2026-11-02 04:29:19 +00:00
Ludovic MarieandClaude Opus 5.5 4c877c4c49 Launcher lock: horizon-world's new log dependency
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:27:27 +01:00
Ludovic MarieandClaude Opus 5.5 03d815532e Keep mobs.rs's modules in order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:26:30 +01:00
Ludovic Marie e12185820a Merge pull request #6613 from horizon-rust/perf/mob-poses-no-alloc
🤖 Agent: poses keyed by Fx hash, vertex hash identical; fmt, clippy and mobs:: (339) pass on the main-merged head.
2026-11-02 05:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 8f91c1534e Format the item render range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:12:04 +01:00
Ludovic MarieandClaude Opus 5.5 916cca3b77 Recipes: the --world launch preload holds the recipe folder too
Its loot data and its server data now share one read, as the title's
world-screen preload does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 00e0ecbdaf Pose mob models on the GPU
Every frame the CPU posed each mob's model parts and pushed 44-byte vertices
for every face (mobs::render::draw_normals): with 1000 mobs in view that was
most of the frame's CPU time. mobs/skin.rs now flattens each model once for
the renderer's store (gfx/entity_skin.rs, the previous commit) and, per
frame, walks the poses like draw_normals but sends one matrix pair per drawn
part and one job per contiguous run; the compute pass writes the vertices.
Culling uses the posed parts' bounds; a culled mob's jobs are taken back.

The vertices are bit for bit the CPU path's: a test poses every mob type at
several ages and yaws both ways and compares them. A mob takes the GPU path
only when all its draws can, so the draw order stays: no custom (CEM)
models, translucency, full-bright layers, energy swirl, armour extras,
outlines, and not players. HORIZON_NO_ENTITY_SKIN=1 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:56:02 +01:00
Ludovic MarieandClaude Opus 5.5 3e642d0acd Renderer: a compute pass that poses entity models
The first half of posing mobs on the GPU (the game side follows): the
renderer keeps a store of model-space vertices (position / 16, UV, axis
normal, part index) that grows as models are handed over, and each frame
runs gfx/entity_skin.wgsl over the frame's jobs (one run of a model's
vertices with its parts' matrices, texture rect, scroll, colour, light and
overlay). The shader writes mesher::Vertex words into the entity buffer
ahead of the CPU-built entities, so every pass that reads the buffer (OIT,
shadows, HDR marks) sees the same vertices as before.

`expand` is the CPU reference: glam's transform order, axis normals taken as
exact normal-matrix columns and `as i8` normal packing, so the shader's
output is bit for bit the CPU path's (test on a real device). The pass needs
compute and four storage buffers, and stays off with a shader pack, a native
pack, frame generation or HORIZON_NO_ENTITY_SKIN=1. Nothing sends frames yet
(`Frame::skin` is None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:55:54 +01:00
Ludovic Marie f31198cbf7 Merge pull request #6721 from horizon-rust/perf-jobs/streaming
Streaming: run on the shared worker pool
2026-11-02 04:55:16 +01:00
Ludovic MarieandClaude Opus 5.5 abaabf5cb0 Mesher: the fill test's column is 32 sections tall
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:49:07 +01:00
Ludovic Marie b82e2a4b35 Merge pull request #6719 from horizon-rust/perf-jobs/pool
🤖 Agent: verified: verify green; pr_ready --quick green (horizon-threads 16 tests, 602 Python tests); the pool has no user yet (skip-release). First of the job-system stack (#6721, #6722).
2026-11-02 04:42:43 +01:00
Ludovic Marie c9434646de Merge pull request #6725 from horizon-rust/perf/low-end-menu
🤖 Agent: verified: pr_ready green (990 render tests, clippy, rustfmt), new tests pass on llvmpipe, blurred menus 2.6 → 7.5 FPS on llvmpipe, GPUs unchanged. Merging.
2026-11-02 04:41:06 +01:00
Ludovic MarieandClaude Opus 5.5 4a4cfcd68f Recipes: the loot data and the server's sync share the recipe folder's read
json_folder moves to horizon-world so loot's load_dir reads through it; the
LAN recipe sync reads through it too. The server-data preload and
SyncData::load hold the recipe folder, so the loot item tables, the recipe
sync and the recipe book's index read each recipe file once, not three times.

Part of #1961.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:41:04 +01:00
Ludovic Marie 9784cf63fd Merge remote-tracking branch 'origin/main' into perf/net-replay-bench 2026-11-02 04:35:39 +01:00
Ludovic Marie 84ad92068f Merge remote-tracking branch 'origin/main' into perf/bench-server-join 2026-11-02 04:35:22 +01:00
Ludovic Marie 79a60dea88 Merge remote-tracking branch 'origin/main' into perf/remote-mob-index 2026-11-02 04:33:50 +01:00
Ludovic Marie 38ce16338c Merge remote-tracking branch 'origin/main' into fix/item-entity-render-distance 2026-11-02 04:33:30 +01:00
Ludovic Marie 2eb2bc1538 Merge remote-tracking branch 'origin/main' into perf/mob-poses-no-alloc 2026-11-02 04:33:12 +01:00
Detimes e0302f4ee6 Merge upstream/main 2026-11-02 03:27:23 +00:00
Detimes 94ac2fb634 Merge upstream/main 2026-11-02 03:26:58 +00:00
Detimes db63176599 Merge upstream/main 2026-11-02 03:26:44 +00:00
Detimes bdee42e267 Merge upstream/main 2026-11-02 03:21:47 +00:00
Detimes 86989096e5 Merge upstream/main 2026-11-02 03:21:09 +00:00
Detimes 185ca1b2af Merge upstream/main 2026-11-02 03:20:51 +00:00
Detimes 78690099a2 Merge upstream/main 2026-11-02 03:20:34 +00:00
Ludovic MarieandClaude Opus 5.5 961618fd6a Mesher: review follow-ups from #6503
- Padded::fill finds a column's section, or the light outside it, through
  the new Chunk::section_at, which Chunk::light now uses too, so the two
  can't disagree.
- MeshScratch::opaque and opaque_rows become solid_rows: they hold
  isSolidRender, not the opaque render layer.
- Docs of sides::sort and SortScratch describe the integer keys.
- A fill test with every neighbour loaded, per-block light, and sections
  at both ends of the column; get_run is checked against the stored values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:20:23 +01:00
Detimes 1fcfcc3029 Merge upstream/main 2026-11-02 03:19:56 +00:00
martipopsandClaude Opus 5.5 afe39ff137 Merge upstream main into the Setup PR
Re-export list conflict in client_lib.rs: main's list (json_folder) plus split_map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:13:41 -05:00
Ludovic MarieandClaude Opus 5.5 d5be28d945 Streaming: run on the shared worker pool
Every streamer (the client's, and one per level on the server thread)
started N-1 worker threads of its own: a single player world ran two to
four such pools at once. They now queue their jobs on the process's
pool (horizon_threads::jobs), sized from the workers setting before any
world opens. Saves and freed meshes go first, then jobs by column
distance, as before; per-worker generator state, face cache and mesh
scratch move into the streamer's per-worker slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 8a6024f8e1 horizon-threads: one shared worker pool for the process (jobs)
A pool of worker threads sized once for the machine, which every source
of background work can share instead of starting its own threads: each
source keeps its queue (so it can rank its jobs again and drop the ones
that left the view) and a free worker runs the most urgent queued job
of any source, by class (urgent, near, far) then the source's key. Far
work never holds every worker. A notify is two atomic operations while
the workers are busy, so the thread queuing work (a frame, a tick) never
waits behind a worker's choice. Per-worker scratch belongs to the
source; dropping a registration waits for its running jobs; idle
workers free their buffers. No user yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 2b874aea1c Menu blur: half size on a software adapter
On WARP or llvmpipe the menu blur took ~250 ms of CPU per frame at 1440x900:
every blurred menu (options, world list, first-launch screen) ran at 3 FPS.
On a software adapter it now runs its six passes at half the width and height
with half the radius, the same blur over a quarter of the pixels. GPUs blur as
before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 3011a79600 Renderer: a chosen graphics API with no GPU tries the others before software
An Intel HD 2500 on Windows has DirectX 12 only through WARP and its GPU through
OpenGL. With DirectX 12 chosen (or set by the launcher), Horizon rendered the
title and every menu on the CPU: 90% CPU and minutes to load (#5185). Auto
already tried every backend on a GPU first (#3985); a chosen API now does too
before it falls back to a software adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
martipopsandClaude Opus 5.5 dbce14ff72 Merge upstream main into the Setup PR
Lang files: both sides' new strings kept; for keys main also added to
fr_fr.json (the Splitscreen Setup page), this branch's wording stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:09:29 -05:00
DetimesandClaude Sonnet 5.5 8128a5937d Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:36 +00:00
DetimesandClaude Sonnet 5.5 55331c9ece Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:33 +00:00
DetimesandClaude Sonnet 5.5 d8f75a8dd5 Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:29 +00:00
DetimesandClaude Sonnet 5.5 8dce381d48 Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:26 +00:00
DetimesandClaude Sonnet 5.5 a235c61c01 Creative slot hook: a cancelled slot is sent back to the creative client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:30:30 +00:00
DetimesandClaude Sonnet 5.5 dd19d7e959 TNT hook: a refused prime plays no fuse sound and emits no game event
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:29:22 +00:00
DetimesandClaude Sonnet 5.5 bd1485f3fb Totem hook: listen only (the health state machine decides before a mod is asked)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:27:22 +00:00
Ludovic MarieandClaude Opus 5.5 ed85828ead Merge main into #6653
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:27:13 +01:00
Ludovic MarieandClaude Opus 5.5 5112fc836c Merge main into #6635
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 b75e56f185 Merge main into #6623
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:26:04 +01:00
Detimes 1795bf290c Merge branch 'feat-player-teleported-hook' of https://github.com/Detimes/horizon into feat-player-teleported-hook 2026-11-02 02:24:23 +00:00
Detimes a366ed22a2 Merge branch 'feat-food-level-hook' of https://github.com/Detimes/horizon into feat-food-level-hook 2026-11-02 02:24:20 +00:00
Detimes bcece942b9 Merge branch 'feat-player-swap-hands-hook' of https://github.com/Detimes/horizon into feat-player-swap-hands-hook 2026-11-02 02:24:17 +00:00
Detimes 6f2273e42c Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:06 +00:00
Detimes 4106cc0ac0 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:05 +00:00
Detimes edb3022de8 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:03 +00:00
Detimes 85268545c0 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:02 +00:00
Detimes c7fd37fcb8 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:00 +00:00
Detimes ee1e81509c Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:59 +00:00
Detimes 53304cdd14 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:57 +00:00
Detimes b90de83c64 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:55 +00:00
Ludovic MarieandClaude Opus 5.5 c35fd5899a Merge main into #6547
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:23:07 +01:00
martipopsandClaude Opus 5.5 587f377a87 Merge upstream main into the Setup PR
Re-export list conflict in client_lib.rs: main's list plus split_map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:11:40 -05:00
martipopsandClaude Opus 5.5 f3d5a8b94b Merge upstream main into the Setup PR
Main lists monitors once a second (#6620); keep that beside this branch's
split_hud.menu in about_to_wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:49:04 -05:00
Ludovic MarieandClaude Opus 5.5 50fdee8614 perf_bench report: runs without --server (tests' Namespace) have no server condition
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:48:06 +01:00
DetimesandClaude Sonnet 5.5 00ad1449e5 Native mods: TNT about to be lit, cancellable (native_tnt_primed)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:46:59 +00:00
DetimesandClaude Sonnet 5.5 d2589c1a6d Docs: drop the paragraph of the hanging break hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:29:45 +00:00
DetimesandClaude Sonnet 5.5 b5541225ea Drop a stray copy of the hanging break hook (not part of this PR)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:29:36 +00:00
DetimesandClaude Sonnet 5.5 f8a0ed7d4d Fix the merge of the hanging place hook: one closing brace too many
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:25:50 +00:00
DetimesandClaude Sonnet 5.5 494184464f Native mods: a server player emptying a bucket, cancellable (native_bucket_empty)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:23:27 +00:00
DetimesandClaude Sonnet 5.5 fbb15eee5f Native mods: a server player placing an item frame, painting or leash knot, cancellable (native_hanging_place)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:23:27 +00:00
DetimesandClaude Sonnet 5.5 860960c596 Drop a stray copy of the teleport hook (it has its own PR)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:10:37 +00:00
DetimesandClaude Sonnet 5.5 b95ce53d06 Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:09:48 +00:00
Ludovic MarieandClaude Opus 5.5 9762e4f3db Mesher: one-biome sections skip the tint blend
When every quart cell the section's blend squares can zoom to holds one
biome, each block's blended tint is that biome's colour (the mean of equal
colours), so Tints::get returns it without the per-block zoom and the
(2r+1)^2 sum. Swamp grass, whose colour follows noise, keeps the blend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:48 +01:00
Ludovic MarieandClaude Opus 5.5 21832e9390 Mesher: face visibility shortcut, call-free rounding, layer-wise fill, bit-row VisGraph
- A face whose neighbour shows a full occlusion face toward it is hidden:
  should_render_face's first test, read from a per-state bit table without
  the visibility lookups.
- round_to_u16 and gray compute f32::round/floor's results without the
  roundf/floorf calls x86-64's baseline makes of them (tested against them).
- Padded::fill finds each chunk column's section once per layer and copies
  fixed-length runs.
- VisGraph's flood fill works on bit rows: each row filled along its open
  runs at once (an occluded fill), regions grown by sweeps over the rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Detimes ff0465854e Merge remote-tracking branch 'upstream/main' into feat-players-connection-hooks 2026-11-02 00:06:19 +00:00
DetimesandClaude Sonnet 5.5 635b622b58 Remove duplicate hook definitions left by the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:06:17 +00:00
Ludovic MarieandClaude Opus 5.5 2b78d2638e perf_bench --server-bots N: a crowd of horizon-bots and a scene that flies over them
horizon-bots prints the first bot's spawn position, so the bench knows where
the crowd walks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:00:45 +01:00
Ludovic MarieandClaude Opus 5.5 20e429c3f8 Find a multiplayer client's mobs by id through an index
A client looked a mob up by a linear search of its mob list for every
movement, data, motion, attribute, equipment and event packet. A Mob is
~3.3 KB, so every compared id is its own cache line: in a 3000-mob farm,
the ~1000 movement packets of one tick took 5.5 ms of searching, 0.11 ms
with 1000 mobs. MobIndex keeps id -> position in a map and checks each hit
against the list; a miss falls back to the linear search and rebuilds the
map, so the list keeps changing freely in its ~35 places and the answer is
always the linear search's (ids are unique: spawn_entity drops the old mob
first).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:56:10 +01:00
Ludovic MarieandClaude Opus 5.5 c481f9c68a Pose mob models without allocating part names
anim::Poses keyed every part by an owned String, so each mob drew a new
String per model part for its body and for every layer (wool, clothes,
armour, eyes) every frame: tens of thousands of small allocations a frame
in a mob farm. It now keys parts by the Fx hash of their name (Fx maps
and sets), with the same lookups and first-name-wins rule; a test checks
that no two part names of any vanilla model share a key.

An ignored benchmark poses the stress_mobs farm (1000 mobs of 8 kinds)
through render_with and hashes the vertices: the same hash before and
after, 6.93 -> 5.73 ms of thread CPU per frame, the body's poses
1.28 -> 0.65 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:44:18 +01:00
DetimesandClaude Sonnet 5.5 878e9d5018 Fix the merge of the login hook: one closing brace too many
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:41:06 +00:00
Ludovic MarieandClaude Opus 5.5 cffc6f24f6 Merge main into #6521
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:32:12 +01:00
Ludovic MarieandClaude Opus 5.5 db3ef8ecbc Merge main into #6520
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:31:35 +01:00
Ludovic MarieandClaude Opus 5.5 912941a8f7 Streaming coordinator: decoration and generation scheduling cost less
While a world loads, Streaming::update ran on one thread and at render
distance 32 took most of it: DecorationOrder::blocked asked waits_for
(36 lookups) for 24 neighbours of every wanted chunk on every update, in a
SipHash map, and schedule_generation scanned every column of the area each
time a worker finished.
- Decoration ranks use the Fx hasher; waits_for answers are kept for the
  pass (nothing it reads changes in it but decorated, which updates them).
- GenerationScan keeps the sorted candidates of a full scan and takes the
  next batches from them while the ranges stay the same and no column left;
  a removal, a dropped proto chunk or a range change scans again.
The decoration and generation order are unchanged (differential test
against the full scan through jobs finishing, columns and protos leaving
and the centre moving).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:27:23 +01:00
Detimes 4ae5d5425d Native mods: a server player's answer to a resource pack (native_resource_pack_status) 2026-11-01 23:24:10 +00:00
Detimes 37a10b81c4 Native mods: a server player's sprint starting or stopping (native_player_sprint) 2026-11-01 23:24:10 +00:00
Detimes 780ac5470a Native mods: the root commands a player's command tree holds (native_player_commands_sent) 2026-11-01 23:24:10 +00:00
Detimes 6d98c0f8d5 Native mods: a joining player the mods may refuse (native_player_login) 2026-11-01 23:24:10 +00:00
Ludovic MarieandClaude Opus 5.5 b73e5b7c98 perf_bench --server horizon|vanilla: clients join a local dedicated server
Every client of the run (Horizon, vanilla, vanilla with the performance
mods) joins the same fresh local server instead of creating a world:
seed 1234, creative, offline, the bench player an operator so the scenes'
/tp and /time work. A new server world gets an unmeasured warm-up join, so
measured joins compare the clients, not the server's generation.
startup.join_ms is connect to loaded. The vanilla driver connects with
ConnectScreen.startConnecting when its script names a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:05:03 +01:00
martipopsandClaude Opus 5.5 b1259ce32a Merge upstream main into the Setup PR
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:58:04 -05:00
Detimes 894c33d745 Native mods: a server player teleported by a command (native_player_teleported) 2026-11-01 22:44:48 +00:00
Detimes aaf377b9ca Native mods: a totem about to save a player, cancellable (native_player_totem) 2026-11-01 22:38:15 +00:00
Ludovic MarieandClaude Opus 5.5 7ec58998d6 Draw dropped items only as far as vanilla does
ItemEntity keeps Entity.shouldRenderAtSqrDistance: 64 times its 0.25 box,
times the view scale, so 24 blocks at render distance 12, 32 at 16 and 40
from 20 on, measured from its current position. Horizon drew them up to 64
blocks, which posed and uploaded every item of a big storage or farm area
that vanilla leaves out. Item pickup particles keep their 64 blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:52:53 +01:00
Ludovic MarieandClaude Opus 5.5 7a52fde28f perf_bench: many-entity stress scenes, per-scene client tick and memory
tools/stress_scenes.py builds nine scenes above the spawn with vanilla
commands (1000 mobs, 2000 dropped items, 512 item frames, 500 armor
stands, 800 containers, 687 signs, 300 banners, steady particles from
redstone-powered command blocks, and an empty baseline) and removes each
afterwards. The same lines run in Horizon (horizon-ctl command) and in
vanilla (the driver mod's new per-scene setup and cleanup).
perf_bench --scenes stress holds each scene still and reports frame CPU,
client tick time (vanilla: Minecraft.tick through a new mixin) and
resident memory per scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:48:34 +01:00
martipopsandClaude Opus 5.5 972519acdc Setup's switch says On/Off from its own keys, not vanilla's ON/OFF
With prepared resources, options.on/off made it "Splitscreen: OFF", unlike
the label before localization and what the live splitscreen tests expect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:14:43 -05:00
martipopsandOpenAI Codex b3dcefa2ac Localize the splitscreen Setup screen and its window feedback
Resolve labels, status text, instructions and warnings from the language table with parameterized messages and bundled English defaults. Add French translations and a process-isolated locale/pack-override regression that preserves draft state and literal player names.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:38:19 -05:00
martipops adea816573 Merge current main into the Setup draft before localization fixes
Co-Authored-By: OpenAI Codex <[email protected]>

# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-01 15:33:16 -05:00
martipopsandOpenAI Codex a5da7abce2 Keep the native mouse regression fixture portable across filesystem paths
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:47 -05:00
martipopsandOpenAI Codex 8ac0773305 Keep native mouse ownership when a second local player joins
Distinguish a visible composited view from a hidden offscreen run. Preserve capture when only the viewport changes, and deliver raw mouse motion only to the keyboard/mouse owner. Add behavioral regressions for capture, real mouse rotation and native-window changes.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:53:16 -05:00
Detimes 4e70c05497 Native mods: a player's food level changing, changeable (native_food_level_changed) 2026-11-01 19:29:24 +00:00
Detimes ab3ebbdd65 Native mods: a player swapping their hands, cancellable (native_player_swap_hands) 2026-11-01 19:29:23 +00:00
martipopsandOpenAI Codex 69ce6f6c26 Merge current main into the Setup rebuild
Adapt the split map re-export to the extracted client library.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:27:24 -05:00
DetimesandClaude Sonnet 5.5 78295e840f Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:17:10 +00:00
DetimesandClaude Sonnet 5.5 69096a5877 Add native_creative_slot hook
Heard when a creative player sets a slot (or throws a stack out) from the
creative screen, after vanilla's checks, cancellable, with the player's
state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:16:54 +00:00
DetimesandClaude Sonnet 5.5 69af122b0d Add native_container_opened hook
Heard when a server player is about to open one of the game's own menus
(chest, furnace, crafting table, villager, station, lectern, horse
inventory), cancellable, with the player's state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:16:53 +00:00
DetimesandClaude Sonnet 5.5 3139a6b8c0 Add native_vehicle_moved hook
Heard once a tick for each boat or minecart that carries a server player
(and for ten seconds after one a player left stood still), after it
moved, with the rider's state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:06:15 +00:00
Ludovic MarieandClaude Opus 5.5 af3a264af1 Net replay bench: horizon-bots --capture, net_replay example
horizon-bots --capture FILE records the first bot's play packets; the
horizon-protocol example net_replay sends them through an in-memory
connection as single player, an offline server and an online-mode server
send them, and prints the CPU of ConnReader::recv, of decoding per packet
type and of parsing chunk sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:46:52 +01:00
DetimesandClaude Sonnet 5.5 4d0553be6e Add native_armor_stand_manipulate hook
Heard when a server player takes an item from an armor stand or puts one
on it, after the stand's disabled-slot checks and before anything moves.
Cancellable.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:02:27 +00:00
martipopsandOpenAI Codex fb0496a105 Align Setup pause icons and seed the saved switch in live checks
Use the effective split-menu flag consistently for drawing, hit testing and keyboard navigation. Setup fixtures enable the saved switch explicitly while keeping HORIZON_SPLIT transient.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 23:23:23 -04:00
d48b592ddd Splitscreen: arrange players with a Setup map and Apply/Revert
Combine draft arrangements and monitor-map projection with the usable Setup screen. Preserve current pause dialogs, HDR settings and keyboard navigation. Mouse dragging and controller pickup follow separately.

Co-Authored-By: OpenAI Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:14:48 -04:00
martipopsandOpenAI Codex 8a65c97f3b Merge the profiles prerequisite onto current main for Setup
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 23:14:48 -04:00
147 changed files with 14371 additions and 1143 deletions

No files matched your search

Generated
+1
View File
@@ -1733,6 +1733,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+12 -6
View File
@@ -564,14 +564,20 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+139 -28
View File
@@ -110,6 +110,7 @@ mod arena;
pub mod compositor;
mod cull;
mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod hdr;
@@ -627,6 +628,9 @@ pub struct Frame<'a> {
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
/// ordinary ones; only when `Renderer::skins_entities` said so.
pub skin: Option<&'a entity_skin::SkinFrame>,
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -856,6 +860,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
@@ -1523,37 +1529,64 @@ async fn open_device(
api: GraphicsApi,
layers: u32,
) -> Result<Opened> {
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
// still has OpenGL).
match open_device_with(window, offscreen, api, layers, false).await {
Ok(opened) => Ok(opened),
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: no GPU adapter could be used ({first}); \
rendering on the CPU, which is slow"
);
Ok(opened)
if let Some(first) = &first {
log::warn!(
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
if allow_cpu {
"rendering on the CPU, which is slow"
} else {
"using the GPU through another one"
}
);
}
return Ok(opened);
}
Err(_) => Err(first),
},
Err(e) => {
first.get_or_insert(e);
}
}
}
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
}
/// `open_device`, taking an adapter of the CPU type (software rendering) only
/// when `allow_cpu`.
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
fn device_search(
api: GraphicsApi,
os: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
.collect();
let mut tries = vec![(chosen.clone(), false)];
if !others.is_empty() {
tries.push((others, false));
}
tries.push((chosen, true));
tries
}
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
/// (software rendering) only when `allow_cpu`.
async fn open_device_with(
window: &Arc<Window>,
offscreen: bool,
api: GraphicsApi,
order: &[wgpu::Backends],
layers: u32,
allow_cpu: bool,
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
for (attempt, backends) in order.iter().copied().enumerate() {
let name = backends_name(backends);
@@ -3443,10 +3476,14 @@ impl Renderer {
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
animate::SpriteAnimator::compute(&adapter, &device),
);
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
@@ -3672,6 +3709,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -4841,6 +4879,23 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
.as_ref()
.map(entity_skin::EntitySkin::stored)
}
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.native.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -6412,6 +6467,13 @@ impl Renderer {
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -6467,7 +6529,7 @@ impl Renderer {
camera,
);
// Frame generation's made frame draws its own hand, halfway, after the others.
let made_hand_vertex = self.entity_batch.combined.len() as i32;
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
@@ -6484,24 +6546,34 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
mapped_at_creation: false,
});
}
if entity_vertices > 0 {
if !all_entities.is_empty() {
self.uploads.buffer(
&mut encoder,
&self.entity_buffer,
0,
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
bytemuck::cast_slice(all_entities),
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -8522,8 +8594,9 @@ impl Renderer {
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
&self.queue,
@@ -8853,6 +8926,13 @@ pub struct Frustum {
planes: [Vec4; 6],
}
impl Frustum {
/// A view's frustum, without bobbing (`Renderer::camera_frustum`'s with
/// no renderer to take the aspect from).
pub fn of_view(view: &View) -> Self {
Self {
planes: frustum(view.view_proj(Mat4::IDENTITY)),
}
}
/// Whether the camera-relative box `min..max` is at least partly inside.
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
!self.planes.iter().any(|p| {
@@ -9312,6 +9392,37 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
#[test]
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
[(vec![all], false), (vec![all], true)]
);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
@@ -0,0 +1,511 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
pub spare: [u32; 3],
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
) {
if frame.jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color: job.color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint: the shader writes what `expand` does,
/// to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
],
vertices: 72,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let before = 5u32;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
}
}
@@ -0,0 +1,110 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order.
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
@@ -969,6 +969,7 @@ mod initial_history_tests {
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
+3 -1
View File
@@ -49,7 +49,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -91,7 +91,9 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_map;
pub mod split_profiles;
pub mod split_ui;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+276 -133
View File
@@ -10,7 +10,7 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, Light, MIN_Y},
graphics::LeafCullingMode,
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
pub color: u32,
}
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
/// baseline instruction set makes of `f32::round`: clamped first (the same
/// result), the fraction is exact below 2^23, and every float above is whole.
#[inline]
fn round_to_u16(v: f32) -> u16 {
let v = v.clamp(0.0, f32::from(u16::MAX));
let whole = v as u32;
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
@@ -82,7 +91,7 @@ impl TerrainVertex {
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
layer,
light,
color,
@@ -93,7 +102,7 @@ impl TerrainVertex {
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
layer,
light,
color,
@@ -116,6 +125,30 @@ mod vertex_tests {
}
}
#[test]
fn uv_rounding_matches_f32_round() {
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
1.5,
65534.5,
1e9,
];
for bits in (0..u32::MAX).step_by(9973) {
values.push(f32::from_bits(bits));
}
for i in 0..=140_000 {
values.push(i as f32 * 0.5 - 2.0);
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
}
for v in values {
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
}
}
#[test]
fn other_uvs_round_within_a_step() {
// A flowing fluid's rotated UV.
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
solid_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
let mut opaque_count = 0;
for y in 0..16 {
for z in 0..16 {
let row = pi(0, y, z);
for x in 0..16 {
let opaque = info[pad.blocks[row + x] as usize].solid;
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
opaque_count += usize::from(opaque);
}
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
/// occluded fill each way, doubling its reach at each step.
#[inline]
fn fill_row(seeds: u16, open: u16) -> u16 {
let (mut up, mut through_up) = (seeds, open);
let (mut down, mut through_down) = (seeds, open);
for step in [1, 2, 4, 8] {
up |= through_up & up << step;
through_up &= through_up << step;
down |= through_down & down >> step;
through_down &= through_down >> step;
}
if opaque_count == 0 {
up | down
}
/// `visibility` on bit rows, one region at a time: a region grows a row at
/// a time (each row filled along its open runs at once) by sweeps over the
/// rows it can reach, until a sweep adds nothing.
fn visibility_rows(open: &[u16; 256]) -> u64 {
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
if open_count == 4096 {
return ALL_VISIBLE;
}
if opaque_count == 4096 {
if open_count == 0 {
return 0;
}
let mut unvisited = *open;
let mut region = [0u16; 256];
let mut result = 0u64;
for start in 0..4096usize {
if seen[start] {
continue;
}
let mut faces = 0u8;
seen[start] = true;
stack.push(start);
while let Some(c) = stack.pop() {
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
if y == 0 {
faces |= 1 << 0;
for start in 0..256 {
while unvisited[start] != 0 {
let seed = unvisited[start] & unvisited[start].wrapping_neg();
region[start] = fill_row(seed, unvisited[start]);
let (mut lo, mut hi) = (start, start);
loop {
let mut grown = false;
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
for r in (first..=last).chain((first..=last).rev()) {
let mut near = region[r];
if r >= 16 {
near |= region[r - 16];
}
if r + 16 < 256 {
near |= region[r + 16];
}
if r & 15 != 0 {
near |= region[r - 1];
}
if r & 15 != 15 {
near |= region[r + 1];
}
let reached = near & unvisited[r];
if reached & !region[r] != 0 {
region[r] = fill_row(reached, unvisited[r]);
(lo, hi) = (lo.min(r), hi.max(r));
grown = true;
}
}
if !grown {
break;
}
}
if y == 15 {
faces |= 1 << 1;
}
if z == 0 {
faces |= 1 << 2;
}
if z == 15 {
faces |= 1 << 3;
}
if x == 0 {
faces |= 1 << 4;
}
if x == 15 {
faces |= 1 << 5;
let mut faces = 0u8;
for r in lo..=hi {
let row = std::mem::take(&mut region[r]);
if row == 0 {
continue;
}
unvisited[r] &= !row;
let (y, z) = (r >> 4, r & 15);
faces |= u8::from(y == 0)
| u8::from(y == 15) << 1
| u8::from(z == 0) << 2
| u8::from(z == 15) << 3
| u8::from(row & 1 != 0) << 4
| u8::from(row & 0x8000 != 0) << 5;
}
// A single connected region touching all six faces sets every
// pair (including the diagonal); unvisited cavities cannot add more.
if faces == 0b11_1111 {
return ALL_VISIBLE;
}
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
continue;
}
let n = (ny << 8 | nz << 4 | nx) as usize;
if !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
}
}
}
}
}
if result == ALL_VISIBLE {
return result;
if result == ALL_VISIBLE {
return result;
}
}
}
result
@@ -509,6 +560,9 @@ pub struct MeshTables {
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
/// By state, bit `d` set where its occlusion face toward direction `d` is the
/// full block (`Info::face` 1): a face looking at it never shows.
full_faces: Vec<u8>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -678,6 +732,10 @@ impl MeshTables {
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
full_faces: info
.iter()
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
.collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -851,68 +909,99 @@ impl Padded {
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read one by one,
// each run of them from a section found once.
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
}
// The x runs of a padded row by the chunk column they fall in.
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
for y in -B..16 + B {
let wy = base_y + y;
// Where each chunk column's cells of this layer come from, found once.
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
for z in -B..16 + B {
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
for (rx, x0, x1) in RUNS {
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
let Some(c) = region[rz][rx] else {
match region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
Some(c) => {
let ez = z.clamp(0, 15);
for (k, x) in (x0..x1).enumerate() {
let ex = x.clamp(0, 15);
blocks[k] = c.get(ex, wy, ez);
light[k] = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
}
continue;
};
// `Chunk::get` and `Chunk::light` with the section found once.
let s = (wy - c.min_y()) >> 4;
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
blocks.fill(0);
light.fill(if s < 0 { 0 } else { 0xF0 });
continue;
};
let at = crate::chunk::index(x0, wy, z);
if rx == 1 && rz == 1 && (0..16).contains(&y) {
let own_row = (y << 8 | z << 4) as usize;
blocks.copy_from_slice(&own[own_row..own_row + 16]);
} else {
section.blocks.get_run(at, blocks);
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
let (middle, east) = rest.split_at_mut(16);
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
let (middle_light, east_light) = rest.split_at_mut(16);
let own_row = (rz == 1 && (0..16).contains(&y))
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
let at = |x: i32| crate::chunk::index(x, wy, z);
let [w, m, e] = &sources[rz];
let edge = (center, wy, z);
fill_run(west, west_light, w, None, at(-B), -B, edge);
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
fill_run(east, east_light, e, None, at(16), 16, edge);
}
}
}
}
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
enum Source<'a> {
/// A neighbour not loaded.
Missing,
/// Above or below the column's sections: air with this light.
Outside(u8),
Section(&'a crate::chunk::Section),
}
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
/// at section index `at`; `own` is the centre section's row, already unpacked.
#[inline(always)]
fn fill_run(
blocks: &mut [u32],
light: &mut [u8],
source: &Source,
own: Option<&[u32]>,
at: usize,
x0: i32,
(center, wy, z): (Option<&Chunk>, i32, i32),
) {
match source {
Source::Section(section) => {
match own {
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -920,14 +1009,13 @@ impl Padded {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
/// is `isSolidRender` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
@@ -949,6 +1037,8 @@ fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -1061,6 +1151,8 @@ struct Tints<'a> {
/// Chunk x, chunk z and the section's lowest block y.
origin: [i32; 3],
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1078,6 +1170,7 @@ impl<'a> Tints<'a> {
plains: biomes.id("minecraft:plains"),
origin,
layers: Default::default(),
uniform: None,
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
@@ -1116,8 +1209,52 @@ impl<'a> Tints<'a> {
plains
}
}
/// The biome every block of the section's blend squares zooms to, when they
/// all do: `quart_at_block` picks one of the eight quart cells at its base
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
/// holds one biome, every zoomed cell is that biome.
fn uniform_biome(&mut self) -> Option<u16> {
if let Some(uniform) = self.uniform {
return uniform;
}
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
// The base cells of blocks lo..=hi, and the cell past the last.
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
let (xs, ys, zs) = (
span(cx * 16 - r, cx * 16 + 15 + r),
span(base_y, base_y + 15),
span(cz * 16 - r, cz * 16 + 15 + r),
);
let first = Self::quart_biome(
self.region,
self.plains,
[cx, cz],
[*xs.start(), *ys.start(), *zs.start()],
);
let uniform = ys
.flat_map(|y| zs.clone().map(move |z| (y, z)))
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
.then_some(first);
self.uniform = Some(uniform);
uniform
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
// One biome over every blend square: the mean of (2r+1)^2 equal
// colours is that colour, unless it varies by position (swamp grass).
if let Some(biome) = self.uniform_biome() {
let info = self.biomes.get(biome);
match resolver {
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
}
Resolver::Grass => {}
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
Resolver::Water => return info.water & 0xFF_FFFF,
}
}
let r = self.radius;
let dim = (16 + 2 * r) as usize;
let layer = self.layers[y as usize].get_or_insert_with(|| {
@@ -1255,9 +1392,9 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1272,6 +1409,7 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
@@ -1329,6 +1467,11 @@ impl Mesher<'_> {
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
};
channel(0) | channel(16) << 16
}
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
/// one lower where that rounded a negative value up (saturating as `as` does).
pub(super) fn gray(brightness: f32) -> i32 {
(brightness * 255.0).floor() as i32
let v = brightness * 255.0;
let whole = v as i32;
if whole as f32 > v {
whole.saturating_sub(1)
} else {
whole
}
}
/// One channel of `ARGB.scaleRGB(color, float)`.
pub(super) fn scale(channel: i32, by: f32) -> i32 {
@@ -406,6 +414,28 @@ mod tests {
);
}
#[test]
fn gray_floors_like_f32_floor() {
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
1.0,
-1.0,
1e10,
-1e10,
];
for bits in (0..u32::MAX).step_by(7919) {
values.push(f32::from_bits(bits));
}
for i in -3000..3000 {
values.push(i as f32 / 1024.0);
}
for v in values {
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
}
}
#[test]
fn colour_arithmetic_matches_argb_helpers() {
assert_eq!(gray(1.0), 255);
assert_eq!(gray(0.8), 204);
@@ -45,6 +45,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -181,7 +240,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
@@ -203,7 +203,8 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
@@ -213,9 +214,9 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
+32 -12
View File
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1512,6 +1512,7 @@ pub struct OptionsUi {
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// Keyboard focus: the interactive target currently focused.
@@ -1528,8 +1529,6 @@ 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 {
@@ -1568,6 +1567,7 @@ impl Default for OptionsUi {
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
focus: None,
can_change_value: false,
@@ -1575,7 +1575,6 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -2122,6 +2121,9 @@ impl OptionsUi {
) {
self.graphics.reset();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
@@ -2262,14 +2264,7 @@ 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::SplitSetup => Vec::new(),
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -3346,6 +3341,11 @@ impl OptionsUi {
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
@@ -3646,6 +3646,26 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::SplitSetup {
use crate::split_ui::Click;
if right {
return Vec::new();
}
self.split_setup.on = s.extras.splitscreen;
return match self.split_setup.click((x, y), size, drag) {
Click::Nothing => Vec::new(),
Click::Pressed => vec![Event::ButtonClick],
Click::Switch => {
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
vec![Event::ButtonClick, Event::Changed]
}
Click::Done => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::SplitProfiles {
use crate::split_profiles::Click;
if right || drag {
+487 -25
View File
@@ -1,5 +1,6 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
//! plays in each, before any of it is applied.
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
}
}
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
/// physical pixels), and its scale.
#[derive(Clone, Debug, PartialEq)]
pub struct Monitor {
pub name: String,
pub at: [i32; 2],
pub size: [u32; 2],
pub scale: f64,
}
impl Monitor {
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
pub fn desktop(&self) -> [f64; 4] {
let k = if cfg!(target_os = "macos") {
self.scale
} else {
1.0
};
[
f64::from(self.at[0]) / k,
f64::from(self.at[1]) / k,
f64::from(self.size[0]) / k,
f64::from(self.size[1]) / k,
]
}
}
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// A new free window's size, in logical pixels.
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
/// monitor (by name).
#[derive(Clone, Debug, PartialEq)]
pub enum Spot {
Window(u8),
New,
Fill(String),
}
/// Something to tell before Apply.
#[derive(Clone, Debug, PartialEq)]
pub enum Warning {
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
Empty(u8),
/// This window fills a monitor that isn't there: its players play in Window 1.
Unplugged(u8, String),
/// This window fills the monitor Window 1 is on: its players play in Window 1.
OnWindow1(u8),
}
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub windows: Vec<Window>,
pub players: Vec<u8>,
pub two: u8,
pub three: u8,
/// The players (indices) in the order their views fill a window shared with others: first
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
pub order: Vec<usize>,
}
impl Draft {
/// Everyone in Window 1 (where it is now).
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
Self {
windows: vec![Window {
number: 1,
placement: window1,
}],
players: vec![1; players],
two,
three,
order: Vec::new(),
}
}
/// Players `i` and `j` trade places: their windows and their places in them.
pub fn swap(&mut self, i: usize, j: usize) {
let n = self.players.len();
if i >= n || j >= n || i == j {
return;
}
self.players.swap(i, j);
let mut order = ordered(&self.order, n);
let (a, b) = (
order.iter().position(|&p| p == i),
order.iter().position(|&p| p == j),
);
if let (Some(a), Some(b)) = (a, b) {
order.swap(a, b);
}
self.order = order;
}
/// Player `i` to place `k` among the others in its window (0: before them all; past the
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
pub fn put_at(&mut self, i: usize, k: usize) {
let n = self.players.len();
if i >= n {
return;
}
let mine = self.players[i];
let mut order: Vec<usize> = ordered(&self.order, n)
.into_iter()
.filter(|&p| p != i)
.collect();
let others: Vec<usize> = order
.iter()
.copied()
.filter(|&p| self.players[p] == mine)
.collect();
let at = match others.get(k) {
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
None => others
.last()
.and_then(|j| order.iter().position(|p| p == j))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, i);
self.order = order;
}
/// The players but `i` in window `number`, in their order.
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
ordered(&self.order, self.players.len())
.into_iter()
.filter(|&p| p != i && self.players[p] == number)
.collect()
}
/// A new free window, numbered with the lowest number free; its number.
pub fn add_window(&mut self) -> u8 {
self.open(Placement::Free(None))
}
fn open(&mut self, placement: Placement) -> u8 {
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(Window { number, placement });
number
}
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
/// number the draft doesn't have is ignored.
pub fn place_player(&mut self, i: usize, spot: Spot) {
if i >= self.players.len() {
return;
}
let number = match spot {
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
Spot::Window(_) => return,
Spot::New => self.add_window(),
Spot::Fill(name) => self.open(Placement::Fill(name)),
};
self.players[i] = number;
}
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
/// so their numbers hold still as players move).
pub fn drop_empty(&mut self, keep: &[u8]) {
let players = &self.players;
self.windows
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
}
/// Whether nobody plays in window `number`.
pub fn empty(&self, number: u8) -> bool {
!self.players.contains(&number)
}
/// What to tell before Apply, in window order.
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
let mut out = Vec::new();
for w in &self.windows {
if let Placement::Fill(name) = &w.placement
&& !monitors.iter().any(|m| &m.name == name)
{
out.push(Warning::Unplugged(w.number, name.clone()));
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
out.push(Warning::OnWindow1(w.number));
} else if !self.players.contains(&w.number) {
out.push(Warning::Empty(w.number));
}
}
out
}
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
let home = monitors
.iter()
.find(|m| m.name == main)
.or(monitors.first());
// Centred on Window 1's monitor, as the game opens a new window.
let new = |number: u8| {
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
let at = m.desktop();
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
});
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
let step = cascade(number) * scale * k;
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
([x as i32, y as i32], size)
};
self.windows
.iter()
.map(|w| {
let rect = match &w.placement {
Placement::Free(Some(rect)) => Some(*rect),
Placement::Free(None) => Some(new(w.number)),
Placement::Fill(name) if w.number != 1 && name == main => None,
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
let at = m.desktop();
([at[0] as i32, at[1] as i32], m.size)
}),
};
// Everyone with nowhere else plays in Window 1: it never has no size.
if w.number == 1 {
rect.or_else(|| Some(new(1)))
} else {
rect
}
})
.collect()
}
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
/// Window 1's.
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
let rects = self.rects(monitors, main);
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
let window1 = index(1).unwrap_or(0);
let windows: Vec<usize> = self
.players
.iter()
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
.collect();
let sizes: Vec<PhysicalSize<u32>> = rects
.iter()
.map(|r| {
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
PhysicalSize::new(w, h)
})
})
.collect();
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
let monitors = [Monitor {
name: "Main".into(),
at: [0, 0],
size: [1920, 1080],
scale: 1.0,
}];
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
assert!(d.rects(&monitors, "Main")[0].is_some());
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
d.place_player(1, Spot::Window(9));
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
}
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
@@ -272,6 +549,97 @@ mod tests {
)
);
}
let mut draft = here(16);
draft.place_player(15, Spot::New);
let views = draft.preview(&monitors(), "Built-in");
assert_eq!(views.len(), 16);
assert_ne!(views[0].0, views[15].0);
}
fn monitors() -> Vec<Monitor> {
vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [3840, 2160],
scale: 1.0,
},
]
}
/// P1 in Window 1, everyone else in Window 2 filling the TV.
trait DeskAndTv {
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
}
impl DeskAndTv for Draft {
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
let tv = monitors.iter().find(|m| m.name != main)?;
self.windows.push(Window {
number: 2,
placement: Placement::Fill(tv.name.clone()),
});
for p in self.players.iter_mut().skip(1) {
*p = 2;
}
Some(self)
}
}
fn here(players: usize) -> Draft {
Draft::everyone_here(
players,
Placement::Free(Some(([100, 100], [2000, 1200]))),
0,
0,
)
}
#[test]
fn presets() {
let m = monitors();
// Everyone here: four quarters of Window 1.
let views = here(4).preview(&m, "Built-in");
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
// P1 alone, the others share the TV in rows.
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
assert_eq!(tv.players, [1, 2, 2, 2]);
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
let views = tv.preview(&m, "Built-in");
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
}
#[test]
fn new_windows_cascade_instead_of_stacking() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::New);
d.place_player(2, Spot::New);
let rects = d.rects(&m, "Built-in");
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
// Window 3 a step right and down of Window 2, on the same diagonal.
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
}
#[test]
fn put_at_a_place_among_a_window_s_players() {
let mut d = here(3);
d.put_at(2, 0);
assert_eq!(d.order, [2, 0, 1]);
d.put_at(2, 5); // past the last: after them
assert_eq!(d.order, [0, 1, 2]);
// Players in another window keep their places.
d.players = vec![1, 2, 1];
d.put_at(2, 0);
assert_eq!(d.sharing(2, 1), [0]);
assert_eq!(d.order, [2, 0, 1]);
}
#[test]
@@ -293,4 +661,98 @@ mod tests {
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
#[test]
fn two_players_swap_in_a_window_and_between_windows() {
let m = monitors();
// In one window: P1 and P2 trade sides.
let mut d = here(2);
d.swap(0, 1);
assert_eq!(d.order, [1, 0]);
let views = d.preview(&m, "Built-in");
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.swap(1, 2);
assert_eq!(d.players, [1, 1, 2]);
}
#[test]
fn players_placed() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.place_player(2, Spot::Window(2));
assert_eq!(d.players, [1, 2, 2]);
d.place_player(2, Spot::New);
assert_eq!(d.players, [1, 2, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
// The window left behind stays while the draft is edited (numbers hold still), and
// closes at Apply; Window 1 never.
d.place_player(1, Spot::Window(1));
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
assert_eq!(numbers(&d), [1, 2, 3]);
assert!(d.empty(2));
d.drop_empty(&[]);
assert_eq!(numbers(&d), [1, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
d.place_player(0, Spot::Window(3));
d.place_player(1, Spot::Window(3));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
}
#[test]
fn windows_numbered_lowest_free() {
let mut d = here(3);
assert_eq!(d.add_window(), 2);
assert_eq!(d.add_window(), 3);
d.windows.retain(|w| w.number != 2);
assert_eq!(d.add_window(), 2);
// Nobody to place: no window opens for it.
d.place_player(9, Spot::New);
assert_eq!(d.windows.len(), 3);
}
#[test]
fn a_window_over_window_1s_monitor() {
let m = monitors();
let mut d = here(2);
d.place_player(1, Spot::Fill("Built-in".into()));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
// As the game does: P2 beside P1 in Window 1.
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
}
#[test]
fn warnings_and_an_unplugged_monitor() {
let m = monitors();
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
let three = d.add_window();
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
// The TV unplugged: its player plays in Window 1 beside P1.
assert_eq!(
d.warnings(&m[..1], "Built-in"),
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
);
let views = d.preview(&m[..1], "Built-in");
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
// P1 elsewhere: Window 1 is blank.
d.players = vec![2, 2];
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
}
#[test]
fn eight_players_two_windows() {
let m = monitors();
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
let views = d.preview(&m, "Built-in");
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
let area: u64 = views[1..]
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(area, 3840 * 2160);
assert!(views[1..].iter().all(|v| v.0 == 1));
}
}
@@ -0,0 +1,166 @@
//! Project a draft arrangement onto the Setup screen, without opening a window.
use crate::split_layout::{Draft, Monitor};
/// Map area: below the title and above the move row and footer.
pub const TOP: f32 = 30.0;
pub const BOTTOM: f32 = 80.0;
pub type Rect = [f32; 4];
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
let rects = draft.rects(monitors, main);
let on_desk: Vec<(u8, [f64; 4])> = draft
.windows
.iter()
.zip(&rects)
.filter_map(|(win, rect)| {
let ([x, y], [pw, ph]) = (*rect)?;
let (x, y) = (f64::from(x), f64::from(y));
let scale = monitors
.iter()
.find(|m| {
let [mx, my, mw, mh] = m.desktop();
x >= mx && x < mx + mw && y >= my && y < my + mh
})
.or_else(|| monitors.iter().find(|m| m.name == main))
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
Some((
win.number,
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
))
})
.collect();
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
return Default::default();
}
let (aw, ah) = (
f64::from(w) - 40.0,
// Room under the monitors for their names, and always for the move row: the map
// doesn't jump when a player is picked (a drop back to where its tile was misses).
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
);
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
let (ox, oy) = (
(f64::from(w) - (x1 - x0) * k) / 2.0,
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
);
let to_map = |[x, y, dw, dh]: [f64; 4]| {
[
(ox + (x - x0) * k) as f32,
(oy + (y - y0) * k) as f32,
(dw * k) as f32,
(dh * k) as f32,
]
};
let mapped_monitors = desk.iter().copied().map(to_map).collect();
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
// In their own order, emptied ones too: a window a player was just dragged out of stays
// where it was until Apply closes it, so the player can be dragged back into it.
let tiles = draft
.preview(monitors, main)
.iter()
.map(|&(i, [vx, vy], size)| {
let number = draft.windows.get(i).map_or(1, |w| w.number);
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
return (number, [0.0; 4]);
};
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
(
number,
[
wx + vx as f32 / pw * ww,
wy + vy as f32 / ph * wh,
size.width as f32 / pw * ww,
size.height as f32 / ph * wh,
],
)
})
.collect();
(mapped_monitors, windows, tiles)
}
/// The largest visible side of a tile after later windows cover it.
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
for [cx, cy, cw, ch] in covers {
let [x, y, w, h] = r;
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
continue;
}
let sides = [
[x, y, cx - x, h],
[cx + cw, y, x + w - (cx + cw), h],
[x, y, w, cy - y],
[x, cy + ch, w, y + h - (cy + ch)],
];
r = sides
.into_iter()
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
.unwrap_or([x, y, 0.0, 0.0]);
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::split_layout::{Placement, Spot};
#[test]
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
let monitors = vec![
Monitor {
name: "Desk".into(),
at: [0, 0],
size: [2000, 1200],
scale: 1.0,
},
Monitor {
name: "TV".into(),
at: [-1920, 0],
size: [1920, 1080],
scale: 1.0,
},
];
let mut draft =
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
let (a, b) = (tiles[0].1, tiles[1].1);
assert!((a[2] - b[2]).abs() < 0.01);
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
draft.place_player(1, Spot::Fill("TV".into()));
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert!(screens[1][0] < screens[0][0]);
assert_eq!(windows.len(), 2);
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
}
#[test]
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
let tile = [0.0, 0.0, 100.0, 100.0];
assert_eq!(
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
[60.0, 0.0, 40.0, 100.0]
);
let hidden = uncovered(tile, [tile].into_iter());
assert_eq!(hidden[2] * hidden[3], 0.0);
assert_eq!(
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
tile
);
}
}
@@ -0,0 +1,716 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
graphics_style::STYLE,
hud::{HudAtlas, HudBatch, rgba},
lang,
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
split_map::{self, BOTTOM, uncovered},
text::Text,
ui,
};
/// Resolve against the selected language on every draw, with bundled English
/// defaults even in a resource-free UI test. Arguments use Minecraft's formatter.
fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> String {
static ENGLISH: std::sync::OnceLock<lang::Language> = std::sync::OnceLock::new();
let key = if key.starts_with("horizon.") {
key.to_owned()
} else {
format!("horizon.splitscreen.setup.{key}")
};
let template = lang::current()
.and_then(|language| language.get(&key).map(str::to_owned))
.unwrap_or_else(|| {
ENGLISH
.get_or_init(|| lang::Language::load(&[], "en_us"))
.get(&key)
.unwrap_or(&key)
.to_owned()
});
let args: Vec<_> = args
.iter()
.map(|arg| Text::literal(arg.to_string()))
.collect();
crate::text::format_translation(&template, &args).plain()
}
#[derive(Default)]
pub struct SplitUi {
pub on: bool,
pub idle: bool,
pub live: Option<Draft>,
pub monitors: Vec<Monitor>,
pub main: String,
pub names: Vec<String>,
pub apply: Option<Draft>,
edit: Option<Draft>,
selected: Option<usize>,
}
#[derive(Clone)]
enum Target {
Player(usize),
Move(Spot),
Revert,
Apply,
Done,
Switch,
}
type Button = ([f32; 4], String, Target, bool);
pub enum Click {
Nothing,
Pressed,
Done,
Switch,
}
impl SplitUi {
pub fn reset(&mut self) {
self.edit = None;
self.selected = None;
self.apply = None;
}
fn draft(&self) -> Option<&Draft> {
self.edit.as_ref().or(self.live.as_ref())
}
fn map(&self, w: f32, h: f32) -> split_map::Map {
if self.idle {
return Default::default();
}
self.draft().map_or_else(Default::default, |draft| {
split_map::project(draft, &self.monitors, &self.main, w, h)
})
}
fn editing(&mut self) -> Option<&mut Draft> {
if self.edit.is_none() {
self.edit = self.live.clone();
}
self.edit.as_mut()
}
fn monitor_name(&self, name: &str) -> String {
match self.monitors.iter().position(|m| m.name == name) {
Some(n) => tr("monitor", &[&(n + 1)]),
None => tr("monitor_unknown", &[]),
}
}
/// Where the selected player can go: the other windows, a new one, a monitor no window fills.
fn spots(&self) -> Vec<(String, Spot)> {
let (Some(draft), Some(i)) = (self.draft(), self.selected) else {
return Vec::new();
};
let mine = draft.players.get(i).copied();
let mut spots: Vec<(String, Spot)> = draft
.windows
.iter()
.filter(|w| Some(w.number) != mine)
.map(|w| (tr("window", &[&w.number]), Spot::Window(w.number)))
.collect();
spots.push((tr("new_window", &[]), Spot::New));
for m in &self.monitors {
let filled = draft
.windows
.iter()
.any(|w| w.placement == Placement::Fill(m.name.clone()));
if m.name != self.main && !filled {
spots.push((
tr("fill_monitor", &[&self.monitor_name(&m.name)]),
Spot::Fill(m.name.clone()),
));
}
}
spots
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
let row = |out: &mut Vec<Button>, y: f32, items: Vec<(f32, String, Target, bool)>| {
let total: f32 = items.iter().map(|i| i.0 + 4.0).sum::<f32>() - 4.0;
let mut x = ((w - total) / 2.0).floor();
for (bw, label, target, active) in items {
out.push(([x, y, bw, 20.0], label, target, active));
x += bw + 4.0;
}
};
let done = (
64.0,
lang::get_or("gui.done", &tr("done", &[])),
Target::Done,
true,
);
// The switch, top right (P1's): off only once P1 plays alone (turning it off with others
// in drops them all at once).
let switch = (
[w - 110.0, 6.0, 104.0, 20.0],
tr(
"switch",
&[
&tr("horizon.extras.splitscreen", &[]),
&tr(if self.on { "on" } else { "off" }, &[]),
],
),
Target::Switch,
!self.on || self.names.len() <= 1,
);
if !self.on || self.idle {
let mut out = vec![switch];
row(&mut out, h - 27.0, vec![done]);
return out;
}
let (_, windows, tiles) = self.map(w, h);
let mut order: Vec<usize> = (0..tiles.len()).collect();
order.sort_by_key(|&i| windows.iter().position(|(n, _)| *n == tiles[i].0));
let mut out: Vec<Button> = order
.into_iter()
.map(|i| {
let name = self
.names
.get(i)
.cloned()
.unwrap_or_else(|| tr("player", &[&(i + 1)]));
// The part no later window covers: what can be seen and pressed.
let at = windows.iter().position(|(n, _)| *n == tiles[i].0);
let covers = at.map_or(&[][..], |k| &windows[k + 1..]);
let rect = uncovered(tiles[i].1, covers.iter().map(|(_, r)| *r));
(rect, name, Target::Player(i), true)
})
.collect();
// The move row: one button for the windows, which cycles (the next window after the
// player's, by number, wrapping), then a new window and the monitors.
let mut spots = self.spots();
let mine = self
.selected
.and_then(|i| self.draft().and_then(|d| d.players.get(i).copied()))
.unwrap_or(0);
let next = spots
.iter()
.filter_map(|(_, s)| match s {
Spot::Window(n) => Some(*n),
_ => None,
})
.min_by_key(|&n| (n <= mine, n));
spots.retain(|(_, s)| !matches!(s, Spot::Window(n) if Some(*n) != next));
if !spots.is_empty() {
let items = spots
.into_iter()
.map(|(label, spot)| (86.0, label, Target::Move(spot), true))
.collect();
row(&mut out, h - BOTTOM + 4.0, items);
}
out.push(switch);
let mut footer = Vec::new();
let editing = self.changed();
footer.extend([
(64.0, tr("revert", &[]), Target::Revert, editing),
(64.0, tr("apply", &[]), Target::Apply, editing),
done,
]);
row(&mut out, h - 27.0, footer);
out
}
fn warnings(&self) -> Vec<String> {
let Some(draft) = self.draft() else {
return Vec::new();
};
draft
.warnings(&self.monitors, &self.main)
.into_iter()
.map(|w| match w {
Warning::Empty(1) => tr("warning.empty_main", &[]),
// One open now closes; one only in the draft never opens.
Warning::Empty(n) if self.live.as_ref().is_some_and(|l| !l.empty(n)) => {
tr("warning.empty_close", &[&n])
}
Warning::Empty(n) => tr("warning.empty_new", &[&n]),
Warning::OnWindow1(n) => tr("warning.on_main", &[&n]),
Warning::Unplugged(n, _) => tr("warning.unplugged", &[&n]),
})
.collect()
}
/// The state represented by the preview area, also available to agent controls.
pub fn status(&self) -> &'static str {
if !self.on {
"off"
} else if self.idle {
"idle"
} else {
"active"
}
}
fn status_lines(&self) -> Option<[String; 3]> {
if !self.on {
Some([
tr("status.off", &[]),
tr("status.enable_hint", &[]),
tr("status.join_hint", &[]),
])
} else if self.idle {
Some([
tr("status.idle", &[]),
tr("status.alone", &[]),
tr("status.solo_controls", &[]),
])
} else {
None
}
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
STYLE.title(
batch,
atlas,
&Text::literal(tr("horizon.pages.split_setup", &[])),
);
if let Some(lines) = self.status_lines() {
let grey = rgba(170, 170, 170, 255);
let lines: Vec<_> = lines
.iter()
.flat_map(|line| atlas.font.split(&Text::literal(line), (w - 40.0).max(10.0)))
.collect();
let top = h / 2.0 - 6.0 * lines.len() as f32;
for (k, line) in lines.into_iter().enumerate() {
let y = (top + 12.0 * k as f32).floor();
ui::centered_text(batch, atlas, &line, 0.0, y, w, grey);
}
for b in &self.buttons(w, h) {
self.draw_button(batch, atlas, cursor, b);
}
return;
}
let (monitors, windows, tiles) = self.map(w, h);
let grey = rgba(160, 160, 160, 255);
for &[x, y, mw, mh] in &monitors {
batch.fill(atlas, x, y, mw, mh, rgba(255, 255, 255, 60));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
mw - 2.0,
mh - 2.0,
rgba(0, 0, 0, 170),
);
}
let buttons = self.buttons(w, h);
let yellow = rgba(255, 255, 85, 255);
// Each window, then its players: a window over another covers it.
let draft = self.draft();
// A window nobody plays in any more stays until Apply: grey, under the others.
let emptied = |n: u8| n != 1 && draft.is_some_and(|d| d.empty(n));
for &(number, [x, y, ww, wh]) in &windows {
let edge = match emptied(number) {
true => rgba(160, 160, 160, 120),
false => rgba(255, 255, 85, 200),
};
batch.fill(atlas, x, y, ww, wh, edge);
if emptied(number) {
let label = Text::literal(tr("window_empty", &[&number]));
batch.text_styled(atlas, &label, x + 3.0, y - 10.0, grey, true);
}
batch.fill(
atlas,
x + 1.0,
y + 1.0,
ww - 2.0,
wh - 2.0,
rgba(40, 40, 40, 230),
);
for b in buttons
.iter()
.filter(|b| matches!(b.2, Target::Player(i) if tiles[i].0 == number))
{
self.draw_button(batch, atlas, cursor, b);
}
}
// The windows' names over everything, so another window's tiles never hide them.
for (number, [x, y, _, _]) in windows.iter().copied().filter(|w| !emptied(w.0)) {
let (label, color) = (tr("window", &[&number]), yellow);
// A title above this window can land on another player's visible tile.
let covered = windows.iter().any(|(n, r)| {
*n != number && ui::hovered((x + 3.0, y - 10.0), r[0], r[1], r[2], r[3])
});
let title_y = if covered { y + 3.0 } else { y - 10.0 };
batch.text_styled(atlas, &Text::literal(label), x + 3.0, title_y, color, true);
}
for b in buttons.iter().filter(|b| !matches!(b.2, Target::Player(_))) {
self.draw_button(batch, atlas, cursor, b);
}
// The monitors' names under them, where no window covers them.
for (n, [x, y, _, mh]) in monitors.into_iter().enumerate() {
let label = Text::literal(tr("monitor", &[&(n + 1)]));
batch.text_styled(atlas, &label, x + 1.0, y + mh + 2.0, grey, true);
}
self.draw_notes(batch, atlas, cursor, &buttons);
}
fn draw_button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
([x, y, bw, bh], label, target, active): &Button,
) {
let (mut x, mut y, mut bw, mut bh) = (*x, *y, *bw, *bh);
let hover = *active && ui::hovered(cursor, x, y, bw, bh);
let selected = matches!(target, Target::Player(i) if Some(*i) == self.selected);
if matches!(target, Target::Player(_)) {
(x, y, bw, bh) = (x + 1.0, y + 1.0, (bw - 2.0).max(4.0), (bh - 2.0).max(4.0));
}
// The selected player's name in yellow: picked, not just under the pointer.
let text = if selected {
Text::colored(label.as_str(), crate::text::YELLOW)
} else {
Text::literal(label.as_str())
};
ui::button(
batch,
atlas,
x,
y,
bw,
bh,
&text,
*active,
hover || selected,
);
}
fn draw_notes(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
buttons: &[Button],
) {
let (w, h) = (batch.width, batch.height);
let note = match self.selected {
None => tr("note.select", &[]),
Some(_) if self.edit.is_some() => tr("note.apply", &[]),
Some(i) => tr(
"note.place",
&[&self
.names
.get(i)
.cloned()
.unwrap_or_else(|| tr("this_player", &[]))],
),
};
for (k, (line, color)) in std::iter::once((note, rgba(170, 170, 170, 255)))
.chain(
self.warnings()
.into_iter()
.map(|w| (w, rgba(255, 170, 85, 255))),
)
.take(2)
.enumerate()
{
ui::centered_text(
batch,
atlas,
&Text::literal(line),
0.0,
h - BOTTOM + 28.0 + k as f32 * 11.0,
w,
color,
);
}
if buttons.iter().any(|(r, _, t, active)| {
!active && matches!(t, Target::Switch) && ui::hovered(cursor, r[0], r[1], r[2], r[3])
}) {
ui::tooltip(
batch,
atlas,
&atlas
.font
.split(&Text::literal(tr("switch_disabled", &[])), 170.0),
cursor,
);
}
}
/// A press at `(x, y)` on a screen `w` x `h`, or the mouse moving with it held (`drag`).
pub fn click(&mut self, (x, y): (f32, f32), (w, h): (f32, f32), drag: bool) -> Click {
if drag {
return Click::Nothing;
}
// The topmost: the last drawn.
let hit = self
.buttons(w, h)
.into_iter()
.rev()
.find(|([bx, by, bw, bh], _, _, active)| {
*active && ui::hovered((x, y), *bx, *by, *bw, *bh)
});
let Some((_, _, target, _)) = hit else {
return Click::Nothing;
};
match target {
Target::Player(i) => {
self.selected = Some(i);
}
Target::Move(spot) => {
if let Some(i) = self.selected {
self.place(i, spot);
}
}
Target::Revert => self.edit = None,
Target::Apply => {
// Windows emptied in the draft close now, not as players moved.
self.apply = self.edit.take().map(|mut d| {
d.drop_empty(&[]);
d
});
}
Target::Done => {
self.reset();
return Click::Done;
}
Target::Switch => {
self.reset();
return Click::Switch;
}
}
Click::Pressed
}
fn changed(&self) -> bool {
// A free window's spot moves under the draft as it's dragged around: not a change.
let windows = |d: &Draft| -> Vec<(u8, Option<String>)> {
let fill = |p: &Placement| match p {
Placement::Fill(name) => Some(name.clone()),
Placement::Free(_) => None,
};
d.windows
.iter()
.map(|w| (w.number, fill(&w.placement)))
.collect()
};
// The order only counts among players sharing a window (who is first in it).
let order = |d: &Draft| -> Vec<(u8, usize)> {
let mut by_window: Vec<(u8, usize)> = ordered(&d.order, d.players.len())
.into_iter()
.filter_map(|i| d.players.get(i).map(|&n| (n, i)))
.collect();
by_window.sort_by_key(|&(n, _)| n);
by_window
};
let same = |a: &Draft, b: &Draft| {
windows(a) == windows(b)
&& a.players == b.players
&& (a.two, a.three) == (b.two, b.three)
&& order(a) == order(b)
};
match (&self.edit, &self.live) {
(Some(edit), Some(live)) => !same(edit, live),
(edit, _) => edit.is_some(),
}
}
/// Player `i` to `spot` in the draft.
fn place(&mut self, i: usize, spot: Spot) {
let open = self.open_now();
if let Some(draft) = self.editing() {
draft.place_player(i, spot);
draft.drop_empty(&open);
}
}
/// The windows open now: emptied in a draft, they stay until Apply (a window the draft
/// added goes as soon as nobody is in it).
fn open_now(&self) -> Vec<u8> {
self.live
.as_ref()
.map_or_else(Vec::new, |l| l.windows.iter().map(|w| w.number).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: (f32, f32) = (427.0, 300.0);
fn screen(players: usize) -> SplitUi {
SplitUi {
live: Some(Draft::everyone_here(
players,
Placement::Free(Some(([0, 0], [2000, 1200]))),
0,
0,
)),
monitors: vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [1920, 1080],
scale: 1.0,
},
],
main: "Built-in".into(),
names: (1..=players).map(|n| format!("P{n}")).collect(),
on: true,
..Default::default()
}
}
fn labels(ui: &SplitUi) -> Vec<String> {
ui.buttons(SIZE.0, SIZE.1)
.into_iter()
.map(|b| b.1)
.collect()
}
#[test]
fn off_only_the_switch_and_done() {
let mut ui = screen(2);
ui.on = false;
assert_eq!(labels(&ui), ["Splitscreen: Off", "Done"]);
let r = rect(&ui, "Splitscreen: Off");
assert!(matches!(
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false),
Click::Switch
));
}
fn rect(ui: &SplitUi, label: &str) -> [f32; 4] {
ui.buttons(SIZE.0, SIZE.1)
.into_iter()
.find(|(_, l, _, _)| l == label)
.unwrap_or_else(|| panic!("no {label}"))
.0
}
fn press(ui: &mut SplitUi, label: &str) {
let r = rect(ui, label);
ui.click((r[0] + 1.0, r[1] + 1.0), SIZE, false);
}
#[test]
fn move_a_player_then_apply() {
let mut ui = screen(2);
press(&mut ui, "P2");
press(&mut ui, "Fill Monitor 2");
// Nothing applied yet: the draft only.
assert_eq!(ui.apply, None);
assert_eq!(ui.live.as_ref().unwrap().players, [1, 1]);
press(&mut ui, "Apply");
let applied = ui.apply.take().unwrap();
assert_eq!(applied.players, [1, 2]);
assert_eq!(applied.windows[1].placement, Placement::Fill("TV".into()));
}
#[test]
fn revert_drops_the_draft() {
let mut ui = screen(2);
press(&mut ui, "P2");
press(&mut ui, "New window");
assert!(ui.edit.is_some());
press(&mut ui, "Revert");
assert!(ui.edit.is_none());
// Done closes and forgets the selection.
press(&mut ui, "Done");
assert_eq!(ui.selected, None);
}
#[test]
fn locale_switch_and_pack_overrides_keep_the_same_draft() {
// The language table is process-wide. Run this test alone in a child so
// other UI tests cannot observe its temporary French language.
const CHILD: &str = "HORIZON_TEST_SPLIT_SETUP_LOCALE";
if std::env::var_os(CHILD).is_none() {
let module = module_path!().split_once("::").unwrap().1;
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
&format!("{module}::locale_switch_and_pack_overrides_keep_the_same_draft"),
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&output.stdout).contains("1 passed;"),
"locale child test did not run: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
struct Pack;
impl lang::ResourceSource for Pack {
fn namespaces(&self) -> Vec<String> {
vec!["horizon".into()]
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/horizon/lang/fr_fr.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"horizon.splitscreen.setup.new_window": "Créer une fenêtre",
"horizon.splitscreen.setup.monitor": "Écran personnalisé %s",
"horizon.splitscreen.setup.fill_monitor": "Occuper %s",
"horizon.splitscreen.setup.warning.empty_main": "Fenêtre principale masquée"
}))
.unwrap()
})
}
}
let mut ui = screen(2);
ui.names[1] = "Élodie %s".into();
lang::set(lang::Language::load(&[], "en_us"));
press(&mut ui, "Élodie %s");
assert!(labels(&ui).contains(&"New window".to_owned()));
lang::set(lang::Language::load(&[], "fr_fr"));
assert!(labels(&ui).contains(&"Nouvelle fenêtre".to_owned()));
assert!(labels(&ui).contains(&"Appliquer".to_owned()));
assert!(
labels(&ui).contains(&"Élodie %s".to_owned()),
"player names remain literal"
);
press(&mut ui, "Nouvelle fenêtre");
let draft = ui.edit.clone().unwrap();
assert_eq!(draft.players, [1, 2]);
lang::set(lang::Language::load(&[&Pack], "fr_fr"));
let translated = labels(&ui);
assert!(translated.contains(&"Créer une fenêtre".to_owned()));
assert!(translated.contains(&"Occuper Écran personnalisé 2".to_owned()));
assert_eq!(
ui.edit,
Some(draft),
"a language change must not reset the arrangement"
);
ui.place(0, Spot::Window(2));
assert!(
ui.warnings()
.contains(&"Fenêtre principale masquée".to_owned())
);
press(&mut ui, "Appliquer");
assert_eq!(ui.apply.take().unwrap().players, [2, 2]);
ui.on = false;
assert_eq!(
ui.status(),
"off",
"agent protocol states stay language independent"
);
assert_eq!(
ui.status_lines().unwrap()[0],
"L'écran partagé est désactivé."
);
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
lang::set(lang::Language::load(&[], "de_de"));
assert_eq!(
ui.status_lines().unwrap()[0],
"Splitscreen is off.",
"untranslated new keys use bundled English"
);
}
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -32,6 +32,17 @@ impl DirtySections {
pub fn take_column(&mut self, x: i32, z: i32) -> SectionMask {
self.columns.remove(&(x, z)).unwrap_or_default()
}
/// Section indexes `sections` of column (x, z), no longer dirty.
pub fn take(&mut self, x: i32, z: i32, sections: SectionMask) {
if let Some(mask) = self.columns.get_mut(&(x, z)) {
for sy in sections.iter() {
mask.remove(sy);
}
if mask.is_empty() {
self.columns.remove(&(x, z));
}
}
}
/// Keep the columns `keep` accepts.
pub fn retain_columns(&mut self, mut keep: impl FnMut(i32, i32) -> bool) {
self.columns.retain(|&(x, z), _| keep(x, z));
@@ -35,16 +35,16 @@
"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.split_setup": "Splitscreen Setup",
"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.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"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.split_setup.tooltip": "The windows and monitors, and who plays where.",
"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.",
"horizon.pages.open.graphics": "Graphics...",
@@ -306,6 +306,35 @@
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
"horizon.splitscreen.setup.window": "Window %s",
"horizon.splitscreen.setup.new_window": "New window",
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
"horizon.splitscreen.setup.done": "Done",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "On",
"horizon.splitscreen.setup.off": "Off",
"horizon.splitscreen.setup.player": "Player %s",
"horizon.splitscreen.setup.revert": "Revert",
"horizon.splitscreen.setup.apply": "Apply",
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
"horizon.splitscreen.setup.status.alone": "No other local players.",
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
"horizon.splitscreen.setup.note.select": "Click a player to choose its window.",
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
"horizon.splitscreen.setup.note.place": "Where should %s play?",
"horizon.splitscreen.setup.this_player": "this player",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
@@ -289,6 +289,40 @@
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
"horizon.splitscreen.setup.monitor": "Écran %s",
"horizon.splitscreen.setup.monitor_unknown": "un écran",
"horizon.splitscreen.setup.window": "Fenêtre %s",
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
"horizon.splitscreen.setup.done": "Terminé",
"horizon.splitscreen.setup.switch": "%s : %s",
"horizon.splitscreen.setup.on": "Activé",
"horizon.splitscreen.setup.off": "Désactivé",
"horizon.splitscreen.setup.player": "Joueur %s",
"horizon.splitscreen.setup.revert": "Rétablir",
"horizon.splitscreen.setup.apply": "Appliquer",
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur pour choisir sa fenêtre.",
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
"horizon.splitscreen.setup.this_player": "ce joueur",
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
"horizon.pages.split_setup": "Configuration de l'écran partagé",
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.extras.splitscreen": "Écran partagé",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
@@ -329,12 +363,7 @@
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.pages.split_setup": "Écran partagé",
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
}
+9 -1
View File
@@ -24,7 +24,15 @@ fn main() {
resource.set_manifest_file(&manifest);
}
if let Err(e) = resource.compile() {
panic!("embedding Windows resources: {e}");
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
// with a space, and a Windows user name often has one: such a build goes on without
// the icon. Release builds run in CI, where the icon stays required.
if std::env::var_os("CI").is_some() {
panic!("embedding Windows resources: {e}");
}
println!(
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
);
}
}
}
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
// Generation and light, on the game's worker pool (`threads` workers).
// Meshing is left out here and measured on its own below.
horizon_threads::jobs::configure(threads);
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
+90 -1
View File
@@ -116,6 +116,39 @@ impl Control {
}
}
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
/// overlap on the GPU, so their times add up to more than the frame.
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
if report
.hardware
.gpu
.as_ref()
.is_some_and(|g| g.backend == "metal")
{
return Ok(false);
}
control.call("gpu-passes on")?;
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
control.call("gpu-passes off")?;
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
let pass: String = pass
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if let Some(ms) = ms.as_f64() {
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
}
}
Ok(true)
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
@@ -499,6 +534,60 @@ mod tests {
let _ = server.join();
}
/// The wait before a flight times the passes and turns the timing off again before the
/// flight; the report gets one metric per pass. Not on Metal.
#[test]
fn the_wait_times_the_passes_but_not_the_flight() {
let time_on = |backend: &str| {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("bench ") {
serde_json::json!({
"frames": 120,
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
})
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
};
let (timed, lines, metrics) = time_on("vulkan");
assert!(timed);
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
assert_eq!(metrics.len(), 3);
let (timed, lines, metrics) = time_on("metal");
assert!(!timed);
assert!(
lines.is_empty(),
"Metal's overlapping passes are not timed: {lines:?}"
);
assert!(metrics.is_empty());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+4 -3
View File
@@ -18,9 +18,9 @@ pub use horizon_client_render::{
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,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, 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;
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn native_ingredient(
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
+98 -11
View File
@@ -183,6 +183,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_index;
mod mob_looting;
mod mob_sounds;
mod mod_api_host;
@@ -385,6 +386,16 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -2289,6 +2300,8 @@ struct Game<'a> {
regrab_on_refocus: bool,
/// Only the local player owning keyboard and mouse changes the native pointer.
keyboard_mouse: bool,
/// Offscreen rendering into a visible splitscreen window still accepts native input.
split_window: bool,
/// This view's corner in its shared window; cursor warps include the offset.
view_at: [u32; 2],
/// Splitscreen: what it draws over this player's view (its badge, questions, notices).
@@ -2343,6 +2356,10 @@ struct Game<'a> {
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)>>,
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
/// and this frame's draws.
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
/// 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.
@@ -2439,6 +2456,8 @@ struct Game<'a> {
/// player's own (taken by `update_world`).
spawn_wait: Vec<(i32, i32)>,
mobs: Vec<Mob>,
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
mob_index: mob_index::MobIndex,
creatures: creatures::Creatures,
sculk: sculk::SculkState,
raiding: raids::Raiding,
@@ -2577,6 +2596,9 @@ struct Game<'a> {
server_trips: Vec<server_travel::Trip>,
/// Players a command sends to another level, once it has run.
level_teleports: Vec<server_travel::LevelTeleport>,
/// Teleports a command made inside a level, for `native_player_teleported`
/// once the player's move has reached their client (`leave_guest`).
teleport_notes: Vec<server_travel::TeleportNote>,
/// A server's levels other than the one in place (game/levels.rs).
levels: levels::Levels,
/// Which pass of a server tick runs (`tick_server`): the first one reads
@@ -2642,6 +2664,8 @@ struct Game<'a> {
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
entity_models: EntityModels,
carts: Vec<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -2913,6 +2937,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
lan_acting_permission: Option<u8>,
/// Their entity id, which the hub (often taken out of `lan` while they
@@ -5012,6 +5039,15 @@ impl Game<'_> {
_ => {}
}
}
// A prime the mods refused is silent: no fuse sound, no game event.
LevelEvent::Sound { pos, ref sound, .. }
if self.tnt_refused == Some(pos)
&& &**sound == "minecraft:entity.tnt.primed" => {}
LevelEvent::GameEvent {
pos,
event: "minecraft:prime_fuse",
..
} if self.tnt_refused == Some(pos) => self.tnt_refused = None,
LevelEvent::Sound {
pos,
sound,
@@ -5101,7 +5137,7 @@ impl Game<'_> {
}
PlayerEffect::InteractLoot(_) => {}
},
LevelEvent::PrimeTnt { pos, rot } => self.add_primed_tnt(pos, rot, 80, None, None),
LevelEvent::PrimeTnt { pos, rot } => self.prime_tnt_event(pos, rot),
LevelEvent::BlockEvent {
pos,
state,
@@ -6417,6 +6453,22 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
fn bucket_empty_cancelled(&mut self, stack: &ItemStack, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let fluid = match &*stack.item {
"minecraft:lava_bucket" => "minecraft:lava",
"minecraft:powder_snow_bucket" => "minecraft:powder_snow",
_ => "minecraft:water",
};
let mut cancel = false;
self.native_bucket_empty(uuid, &name, pos, fluid, &mut cancel);
cancel
}
/// The player as a block's `useItemOn` / `useWithoutItem` sees them.
fn use_context(&self) -> PlayerContext {
PlayerContext {
@@ -7542,6 +7594,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
self.tick_vehicle_watch();
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
horizon_client::spans::time("tick_lecterns", || self.tick_lecterns());
horizon_client::spans::time("tick_maps", || self.tick_maps());
@@ -7964,9 +8017,9 @@ impl Game<'_> {
};
let scale = HudBatch::new(size).scale;
let was_grabbed = self.captured;
if self.options.offscreen {
// `--offscreen` shows no window: the system pointer stays where the
// person at the computer has it, and the grab is the game's own (an
if self.options.offscreen && !self.split_window {
// A genuinely hidden `--offscreen` run leaves the system pointer where
// the person at the computer has it. Its grab is internal (an
// agent turns the view with `mouse-move`).
if capture {
self.mouse.set_ignore_first_move();
@@ -8027,7 +8080,7 @@ impl Game<'_> {
) {
self.cursor = pos;
}
if !self.options.offscreen {
if !self.options.offscreen || self.split_window {
window.set_cursor_visible(!self.captured);
}
}
@@ -13064,7 +13117,7 @@ impl Game<'_> {
let target = self.bucket_target(content, &hit);
let mut canceled = false;
self.native_fill_bucket(stack, target, &mut canceled);
if canceled {
if canceled || self.bucket_empty_cancelled(stack, target) {
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
@@ -15548,6 +15601,7 @@ impl Game<'_> {
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 skin = std::mem::take(self.skin_frame.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -15631,6 +15685,7 @@ impl Game<'_> {
entities: &entities,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
skin: skin.pending().then_some(&skin),
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
colored_lights: &colored_lights,
@@ -15711,6 +15766,18 @@ impl Game<'_> {
*self.entity_motion.get_mut() = entity_motion;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
let mut skin = skin;
// The renderer took the new models when its store grew by them; otherwise they stay
// pending, or the stores restart (`SkinModels::begin`).
if self
.renderer
.as_ref()
.and_then(|r| r.skinned_models())
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
{
skin.handed();
}
*self.skin_frame.get_mut() = skin;
self.see_through_vertices = see_through;
result
}
@@ -16284,6 +16351,14 @@ impl Game<'_> {
(p.position.z.floor() as i32) >> 4,
);
}
// The sections the camera saw last frame mesh first, as vanilla compiles only those.
self.streaming.view =
self.view_frustum
.zip(self.locator_camera)
.map(|(frustum, camera)| horizon_client::streaming::CameraView {
frustum,
eye: camera.0,
});
// The loading screen's chunk map fills ring by ring, as vanilla's.
self.streaming.loading =
self.options.execution_role.is_client() && self.loading_reason().is_some();
@@ -16550,11 +16625,13 @@ impl ApplicationHandler for Game<'_> {
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// The real mouse never steers an offscreen game: its pointer is not grabbed.
// A composited view renders offscreen but its keyboard/mouse owner still
// controls a visible native window. A headless offscreen run never does.
if self.pending_save.is_none()
&& self.captured
&& self.focused
&& !self.options.offscreen
&& self.keyboard_mouse
&& (!self.options.offscreen || self.split_window)
&& self.screen == Screen::Playing
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
@@ -18233,6 +18310,7 @@ impl<'a> Game<'a> {
captured: false,
regrab_on_refocus: false,
keyboard_mouse: true,
split_window: false,
view_at: [0, 0],
split_hud: Default::default(),
focused: true,
@@ -18261,6 +18339,8 @@ impl<'a> Game<'a> {
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
skin_models: Default::default(),
skin_frame: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
@@ -18322,6 +18402,7 @@ impl<'a> Game<'a> {
player_spawn: None,
spawn_wait: Vec::new(),
mobs: Vec::new(),
mob_index: Default::default(),
creatures: Default::default(),
sculk: Default::default(),
raiding: Default::default(),
@@ -18394,6 +18475,7 @@ impl<'a> Game<'a> {
travelling: None,
server_trips: Vec::new(),
level_teleports: Vec::new(),
teleport_notes: Vec::new(),
levels: Default::default(),
level_pass: Default::default(),
portals: Default::default(),
@@ -18434,6 +18516,7 @@ impl<'a> Game<'a> {
cushion_model: prepared.cushion_model,
entity_models: prepared.entity_models,
carts: Vec::new(),
watched_vehicles: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -18588,6 +18671,7 @@ impl<'a> Game<'a> {
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
chat_capture: None,
@@ -19371,6 +19455,9 @@ fn load_resources(
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
let threads = settings.horizon.worker_threads();
// One pool of workers for every level's streaming, the client's and the
// server thread's (horizon_threads::jobs), sized before any world opens.
horizon_threads::jobs::configure(threads);
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
@@ -19953,6 +20040,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
entities: &[],
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
@@ -20689,9 +20777,8 @@ pub fn run_with() -> Result<()> {
horizon_threads::put_helpers_behind();
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
// cores, and a frame that gave its core up (in a GPU submit) waited
// macOS and Windows. While terrain loads, the workers
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
// 10 to 200 ms to get one back.
horizon_threads::favour_current();
}
+10 -3
View File
@@ -500,7 +500,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_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(),
@@ -535,11 +535,18 @@ impl Game<'_> {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn split_setup_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
if self.split_setup_open() {
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -742,7 +749,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() || self.split_profiles_open() {
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1705,7 +1712,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
@@ -600,6 +600,23 @@ impl Game<'_> {
if held.is_none() && current.is_none() {
return false;
}
// A server's mods hear the swap before it happens, and may stop it.
// A stack of several on an occupied slot goes nowhere (below).
let moves = held
.as_ref()
.is_none_or(|h| h.count <= 1 || current.is_none());
if moves
&& target.is_some()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let stand = self.armor_stands[i].id as i32;
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
let mut cancel = false;
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
if cancel {
return true;
}
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
+1 -1
View File
@@ -76,7 +76,7 @@ impl Game<'_> {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
@@ -1483,6 +1483,10 @@ impl Game<'_> {
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1494,6 +1498,24 @@ impl Game<'_> {
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
// A server player's teleport is told to the mods once their
// move is in the hub (`leave_guest`).
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
self.lan_acting,
self.lan_acting_uuid,
self.lan_acting_name.clone(),
from,
) {
self.teleport_notes
.push(super::server_travel::TeleportNote {
conn,
uuid,
name,
from,
to: pos,
dimension: self.dim.id().to_owned(),
});
}
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
+7 -7
View File
@@ -394,7 +394,7 @@ impl Game<'_> {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
@@ -410,7 +410,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
@@ -674,7 +674,7 @@ impl Game<'_> {
let command = format!("give @s {item} {count}");
server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(json!({ "ok": true, "given": count })));
}
let max = info.max_stack;
@@ -727,7 +727,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.world.day_time = time)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
}
ok()
}
@@ -746,7 +746,7 @@ impl Game<'_> {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let value = server
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
@@ -756,7 +756,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
@@ -986,7 +986,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
.ok_or(super::integrated::NO_ANSWER)??;
} else if self.remote.is_none() {
self.begin_control_save(all, reply.clone())?;
return Ok(None);
+82 -17
View File
@@ -3897,9 +3897,11 @@ impl Game<'_> {
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
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();
if self.tnt_prime_allowed(pos) {
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();
}
}
BlockReaction::Light(lit) => {
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
@@ -6098,6 +6100,19 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
@@ -6240,21 +6255,55 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
let render = if layers_only {
horizon_client::mobs::render::render_layers
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
&& !is_player
&& !layers_only
&& outline.is_none()
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
@@ -6302,7 +6351,23 @@ impl Game<'_> {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
+1 -1
View File
@@ -1236,7 +1236,7 @@ impl Game<'_> {
.iter()
.map(|e| (e.user.id, e.level as u8))
.collect();
host.hub.resend_commands();
self.resend_commands_filtered();
}
/// A player on the server, by name.
+6 -2
View File
@@ -1374,7 +1374,7 @@ impl Game<'_> {
None,
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
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()?;
@@ -1435,9 +1435,13 @@ impl Game<'_> {
if self.sulfur_cube_swallow(front, &stack) {
return (shrink(stack), optional_event("tnt"));
}
let target = Self::pos_of(front);
if !self.tnt_prime_allowed(target) {
set_success("tnt", false);
return (Some(stack), FAIL);
}
set_success("tnt", true);
// 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, None, None);
self.audio.play(
@@ -712,6 +712,17 @@ mod tests {
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
/// keeps the default and its box is 0.25 on each side): 24 blocks at
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
#[test]
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
+19
View File
@@ -125,6 +125,17 @@ impl Game<'_> {
.iter()
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
}
/// Asks the mods whether the acting guest may place a hanging entity
/// of type `kind` at `pos` (`native_hanging_place`): true when one
/// cancelled it. A game's own player is not asked.
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let mut cancel = false;
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
cancel
}
/// `ItemFrameItem.useOn`.
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let glow = match &*stack.item {
@@ -162,6 +173,14 @@ impl Game<'_> {
if !self.frame_survives(&frame) {
return false;
}
let kind = if glow {
"minecraft:glow_item_frame"
} else {
"minecraft:item_frame"
};
if self.hanging_place_cancelled(kind, pos) {
return true;
}
self.next_entity += 1;
self.audio
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
+26 -3
View File
@@ -32,12 +32,19 @@ use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
},
thread::JoinHandle,
time::{Duration, Instant},
};
/// How long the window waits for the server thread to answer a `call`.
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
/// The error of a `call` without an answer: the server stopped, or it was
/// busy past `CALL_TIMEOUT` (the log says which).
pub(super) const NO_ANSWER: &str =
"the integrated server stopped or did not answer within 10 s (the game log says which)";
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
const PLAYER_CHUNKS: usize = 49;
@@ -177,7 +184,9 @@ impl Integrated {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
/// wait for its answer; `None` once the server has stopped, or when it has
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
/// tells the two apart.
pub fn call<T: Send + 'static>(
&self,
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
@@ -188,7 +197,21 @@ impl Integrated {
let _ = tx.send(f(g));
}))
.ok()?;
rx.recv_timeout(Duration::from_secs(10)).ok()
match rx.recv_timeout(CALL_TIMEOUT) {
Ok(answer) => Some(answer),
Err(RecvTimeoutError::Timeout) => {
log::warn!(
"Horizon: the server thread did not answer within {} s (running: {}, \
autosaving: {}, smoothed tick {:.1} ms)",
CALL_TIMEOUT.as_secs(),
!self.stopped(),
self.is_saving(),
self.smoothed_tick_ms()
);
None
}
Err(RecvTimeoutError::Disconnected) => None,
}
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) -> Result<()> {
+11 -1
View File
@@ -483,12 +483,22 @@ impl Game<'_> {
}
_ => None,
});
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
// times the view scale (32 at render distance 16), from its current
// position. A pickup particle is not an entity and keeps 64 blocks.
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
let far = if entity {
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
} else {
pos.length_squared() > 64.0 * 64.0
};
if far {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
+26 -3
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
// The hub is the server thread's.
let status = server
.call(|g| g.lan_status())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(status));
}
match words.get(1).copied() {
@@ -1225,7 +1225,7 @@ impl Game<'_> {
// ShareToLanScreen: IntegratedServer.publishServer.
server
.call(move |g| g.publish(settings))
.ok_or("the integrated server stopped")??
.ok_or(super::integrated::NO_ANSWER)??
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
@@ -1708,8 +1708,8 @@ impl Game<'_> {
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
host.hub.resend_commands();
}
self.resend_commands_filtered();
}
pub(super) fn lan_port(&self) -> Option<u16> {
@@ -1884,6 +1884,27 @@ impl Game<'_> {
}
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
let mut cancel = false;
let mut reason = None;
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
if cancel {
let reason = match reason {
Some(text) => {
horizon_client::lan::data::component_nbt(&text.component())
}
None => horizon_protocol::text::translatable(
"multiplayer.disconnect.generic",
&[],
),
};
host.hub.kick(conn, reason);
continue;
}
}
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
@@ -2381,6 +2402,8 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
// The mods say which commands the newcomer's tree keeps.
self.native_filter_commands(host, Some(conn));
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
@@ -663,6 +663,28 @@ impl Game<'_> {
}
// A totem that saved the guest this turn (its hurt set it off).
let totem = self.apply_totem();
// The mods hear the food level the turn changed (eating, hunger,
// starving...), once for the change it made as a whole, with the
// guest's state still swapped in; they may change or undo it before it
// reaches the guest.
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
&& let Some(g) = host.hub.guests.get(swap.i)
&& g.food != now
{
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
let (mut to, mut cancel) = (now, false);
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
if let Some(p) = self.player.as_mut() {
if cancel {
p.health.food.food = from;
p.health.food.saturation = saturation;
} else if to != now {
p.health.food.food = to.clamp(0, 20);
p.health.food.saturation =
p.health.food.saturation.min(p.health.food.food as f32);
}
}
}
let GuestSwap {
conn,
i,
@@ -1025,6 +1047,8 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
}
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
@@ -1098,6 +1122,31 @@ impl Game<'_> {
self.creative = self.mode == GameMode::Creative;
}
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
/// heard with the guest's state swapped in. Asked before the menu that
/// was open closes.
pub(super) fn guest_may_open(
&mut self,
host: &mut LanHost,
conn: u32,
kind: &str,
pos: Option<[i32; 3]>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let mut cancel = false;
let mut hook =
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// Hand a container the guest opened over (ServerPlayer.openMenu);
/// false when it is not one a guest can open.
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
@@ -1246,6 +1295,10 @@ impl Game<'_> {
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
@@ -1294,6 +1347,10 @@ impl Game<'_> {
else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, None) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
@@ -1784,6 +1841,25 @@ impl Game<'_> {
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
// The mods hear the swap first, and may stop it: the
// client, which swapped at once, gets its slots back.
let (uuid, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
// With the player's state swapped in, so that the helpers
// reach them.
let cancel = self
.as_guest(host, conn, |g| {
let mut cancel = false;
g.native_player_swap_hands(uuid, &name, &mut cancel);
cancel
})
.unwrap_or(false);
if cancel {
host.hub.send_inventory(gi);
return;
}
let g = &mut host.hub.guests[gi];
let slot = g.selected;
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
@@ -2034,6 +2110,9 @@ impl Game<'_> {
if !self.creative_stack_valid(host, gi, &stack) {
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
@@ -2058,6 +2137,12 @@ impl Game<'_> {
{
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
// The creative client already holds the stack it sent: treat that as
// what it was told, so the next broadcast sends the real slot back.
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
return;
}
let slot = p.slot as usize;
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
@@ -2096,6 +2181,22 @@ impl Game<'_> {
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
// ServerboundResourcePackPacket in play: the server has nothing to
// do with it (a declined required pack is handled on the connection).
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status(uuid, &name, r.action);
});
}
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
// and sends the packet on when it changed it.
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_player_sprint(uuid, &name, p.action == 1);
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -2452,6 +2553,32 @@ impl Game<'_> {
}
}
/// Whether the mods let a creative player set `slot` to `stack`
/// (`native_creative_slot`), heard with the guest's state swapped in.
fn guest_may_set_creative_slot(
&mut self,
host: &mut LanHost,
conn: u32,
slot: i32,
stack: Option<&ItemStack>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
@@ -469,6 +469,9 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
if !self.guest_may_open(host, conn, "minecraft:crafting", Some(pos)) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
@@ -31,6 +31,9 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
if !self.guest_may_open(host, conn, "minecraft:horse_inventory", None) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
@@ -69,6 +69,10 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu_type, Some(station.pos)) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
@@ -149,6 +153,9 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
if !self.guest_may_open(host, conn, "minecraft:lectern", Some([key.0, key.1, key.2])) {
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
@@ -57,6 +57,10 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
if !self.guest_may_open(host, conn, "minecraft:merchant", None) {
self.merchant_closed(&mut state);
return;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
+110 -1
View File
@@ -9,7 +9,7 @@
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use super::{Dim, Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::lan::hub::no_blocks_in;
use horizon_client::mobs::equine;
@@ -38,7 +38,116 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
/// A boat or minecart a server player rode, followed for
/// `native_vehicle_moved`.
pub(super) struct WatchedVehicle {
id: u32,
dim: Dim,
/// The server player it carries now.
rider: Option<u128>,
/// Where the hook last heard of it.
at: [f64; 3],
/// Ticks it has stood still.
still: u32,
}
impl Game<'_> {
/// Where the boat or minecart `id` of this level is.
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
let at = |v: Vec3| [v.x, v.y, v.z];
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| at(b.position))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| at(c.position))
})
}
/// `VehicleMoveEvent` for the boats and minecarts server players ride
/// (and left less than ten seconds ago): once a tick, after they moved.
pub(super) fn tick_vehicle_watch(&mut self) {
let riders: Vec<(u32, u32, u128)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.conn, g.uuid)))
.collect()
});
if riders.is_empty() && self.watched_vehicles.is_empty() {
return;
}
let dim = self.dim;
for &(id, _, uuid) in &riders {
let Some(at) = self.watched_vehicle_position(id) else {
continue;
};
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
Some(w) => (w.dim, w.rider) = (dim, Some(uuid)),
None => self.watched_vehicles.push(WatchedVehicle {
id,
dim,
rider: Some(uuid),
at,
still: 0,
}),
}
}
let mut kept = Vec::new();
// (rider's connection and UUID, vehicle type, from, to)
let mut moved = Vec::new();
for mut w in std::mem::take(&mut self.watched_vehicles) {
// Another level's vehicle is told of on that level's tick.
if w.dim != dim {
kept.push(w);
continue;
}
// Broken, or gone from this level.
let Some(now) = self.watched_vehicle_position(w.id) else {
continue;
};
let rider = riders.iter().find(|r| r.0 == w.id);
w.rider = rider.map(|r| r.2);
if now != w.at {
let from = std::mem::replace(&mut w.at, now);
w.still = 0;
let kind = self.target_type(
self.level_entity(w.id as i32)
.unwrap_or(super::commands::Target::Boat(w.id)),
);
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
} else {
w.still += 1;
if w.rider.is_none() && w.still > 200 {
continue;
}
}
kept.push(w);
}
self.watched_vehicles = kept;
// The rider's state is swapped in for the call, so the helpers reach
// them; a vehicle nobody rides is told of with nobody swapped in.
for (rider, kind, from, to) in moved {
let Some((conn, uuid)) = rider else {
self.native_vehicle_moved(None, &kind, from, to);
continue;
};
let Some(mut host) = self.lan.take() else {
continue;
};
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
}
self.lan = Some(host);
}
}
/// The box of a boat, minecart, armor stand, item frame or painting, and
/// the point an interact packet's location is measured from (the
/// client's `remote_object_hit` uses the same points).
+8
View File
@@ -401,6 +401,14 @@ impl Game<'_> {
{
return false;
}
// A knot is made when a lead is tied to a fence that has none.
if existing.is_empty()
&& existing_boats.is_empty()
&& (!in_hand.is_empty() || !boats_in_hand.is_empty())
&& self.hanging_place_cancelled("minecraft:leash_knot", knot_pos)
{
return true;
}
let mut attached = self.tie_player_boats(center, Holder::Knot(knot_pos));
for j in in_hand {
if self.can_attach_lead(j, center) {
-1
View File
@@ -40,7 +40,6 @@ pub(super) struct Decoration {
}
/// A map as a native mod sees it when `native_map_decorations` is asked.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeMapInfo<'a> {
/// The map's id (`map_<id>`).
@@ -0,0 +1,90 @@
//! Finding a mob by its network id without walking the whole list: a
//! multiplayer client looks one up for every movement, data, motion and
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
//! way, against ~0.01 ms through the index.
//!
//! The index is a cache: the mob list changes in many places (spawns,
//! removals, dimension swaps), and none of them has to tell it. A hit is
//! checked against the list, and a miss falls back to the linear search,
//! which rebuilds the index when it finds the mob. Ids are unique in the list
//! (`spawn_entity` drops a mob with the same id before adding one), so the
//! answer is always the linear search's.
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mobs::Mob;
#[derive(Default)]
pub(super) struct MobIndex {
slots: FxHashMap<u32, u32>,
}
impl MobIndex {
/// The position of the mob with this id in `mobs`, as
/// `mobs.iter().position(|m| m.id == id)`.
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
self.position(mobs, |m| m.id, id)
}
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
if let Some(&i) = self.slots.get(&id)
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
{
return Some(i as usize);
}
let i = items.iter().position(|m| id_of(m) == id)?;
self.slots.clear();
for (i, m) in items.iter().enumerate() {
self.slots.entry(id_of(m)).or_insert(i as u32);
}
Some(i)
}
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
let i = self.find(mobs, id)?;
Some(&mut mobs[i])
}
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
let i = self.find(mobs, id)?;
Some(&mobs[i])
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whatever the list went through since the last lookup, the index
/// answers as the linear search does.
#[test]
fn finds_what_a_linear_search_finds_after_any_change() {
let mut index = MobIndex::default();
let check = |index: &mut MobIndex, ids: &[u32]| {
for id in 0..20 {
let found = index.position(ids, |&m| m, id);
assert_eq!(
found,
ids.iter().position(|&m| m == id),
"id {id} in {ids:?}"
);
}
};
let mut ids = vec![7, 3, 9, 12];
check(&mut index, &ids);
ids.remove(1);
check(&mut index, &ids);
ids.push(3);
ids.swap(0, 2);
check(&mut index, &ids);
ids.retain(|&m| m != 9);
ids.insert(0, 15);
check(&mut index, &ids);
// Another list altogether: a dimension swapped in.
check(&mut index, &[12, 15, 1]);
ids.clear();
check(&mut index, &ids);
}
}
+109
View File
@@ -159,6 +159,115 @@ impl Host for super::Game<'_> {
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
self.native_edit_blocks(dimension, edit)
}
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
self.native_surface_heights(dimension, columns, heightmap)
}
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
self.native_read_columns(dimension, columns, heightmap, range)
}
fn refresh_biome_colors(&mut self) {
self.native_refresh_biome_colors()
}
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
self.native_neighbor_signal(dimension, pos)
}
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
self.native_light(pos)
}
fn weather(&self) -> (f32, f32) {
self.native_weather()
}
fn day_time(&self) -> i64 {
self.native_day_time()
}
fn world_height(&self) -> (i32, i32) {
self.native_world_height()
}
fn max_stack_size(&self, item: &str) -> Option<u32> {
self.native_max_stack_size(item)
}
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
self.native_item_model(batch, item, model, at, size)
}
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
self.native_quad(batch, points, argb)
}
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
self.native_mod_screen_open(mod_id)
}
fn mod_screen_close(&mut self, mod_id: &str) {
self.native_mod_screen_close(mod_id)
}
fn mod_screen(&self) -> Option<&str> {
self.native_mod_screen()
}
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
self.native_set_experience_orb(dimension, id, value, count, age)
}
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
self.native_experience_orbs(dimension, min, max)
}
fn clock(&self) -> Option<NativeClock> {
self.native_clock()
}
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
}
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.native_block_state_property(state, key)
}
fn raycast(&self, distance: f32) -> Option<NativeHit> {
self.native_raycast(distance)
}
fn view_hit(&self) -> Option<(f32, NativeHit)> {
self.native_view_hit()
}
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_claim_items(mod_id, ids)
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_poll(mod_id)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
@@ -43,7 +43,6 @@ fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bo
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
@@ -174,7 +173,6 @@ impl BlockEntityDraw {
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
@@ -5,36 +5,33 @@ use super::Game;
use std::collections::BTreeMap;
/// A stack as a client mod's use events carry it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHeldItem {
pub(crate) id: String,
pub(crate) count: u32,
pub struct NativeHeldItem {
pub id: String,
pub count: u32,
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
pub(crate) slot: u32,
pub slot: u32,
/// `minecraft:custom_name` as SNBT, when the stack has one.
pub(crate) custom_name: Option<String>,
pub custom_name: Option<String>,
/// `minecraft:custom_data` as SNBT, when the stack has some.
pub(crate) custom_data: Option<String>,
pub custom_data: Option<String>,
}
/// The block a ray along the view meets.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeHit {
pub(crate) block_x: i32,
pub(crate) block_y: i32,
pub(crate) block_z: i32,
pub struct NativeHit {
pub block_x: i32,
pub block_y: i32,
pub block_z: i32,
/// Where the ray meets the block.
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
pub x: f64,
pub y: f64,
pub z: f64,
/// The block's id (`minecraft:stone`).
pub(crate) block: String,
pub block: String,
}
/// What happened to a mod's claimed item, oldest first.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum NativeModEvent {
pub enum NativeModEvent {
/// A right-click with the item began its use: the stack, and 0 ticks.
UseStart(NativeHeldItem, u32),
/// The use ended: the stack it began with, and the ticks it was held. It
@@ -15,12 +15,12 @@ use std::collections::{BTreeMap, HashMap, HashSet};
/// A data map type a mod registers (`RegisterDataMapTypesEvent`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeDataMapType {
pub struct NativeDataMapType {
/// `mod_id:name`: not in the `minecraft` namespace. Its files are
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`.
pub(crate) id: String,
pub id: String,
/// The registry it is for: `minecraft:item` or `minecraft:block`.
pub(crate) registry: String,
pub registry: String,
}
/// The registries data maps can be attached to so far, with their folder
@@ -16,7 +16,7 @@ const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
/// What a mod's falling block does when it lands and when it hits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeFalling {
pub struct NativeFalling {
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
/// can't be placed.
pub drop_item: bool,
File diff suppressed because it is too large. Load diff
@@ -20,7 +20,6 @@ use horizon_client::{
/// `LivingEntityRenderState`: what a model reads to pose and animate itself.
/// Angles are in degrees, interpolated to the frame's partial tick.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct NativeMobRender<'a> {
/// The mob's network id, as the mob helpers and mod messages name it.
@@ -83,7 +82,6 @@ impl<'a> NativeMobRender<'a> {
/// Which player a [`NativeMobRender`] is, for `native_player_submit` and
/// `native_mob_layer`.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativePlayerRender<'a> {
/// The player's profile name.
@@ -722,9 +722,8 @@ impl Game<'_> {
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
pub struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
@@ -893,6 +892,49 @@ impl Game<'_> {
host.native_commands = Some(levels);
}
/// Asks the mods what root commands each player's tree keeps
/// (`native_player_commands_sent`) and stores the answer in the hub,
/// which sends the trees without what the mods took out: for `only`, or
/// for every placed player.
pub(super) fn native_filter_commands(&mut self, host: &mut LanHost, only: Option<u32>) {
let players: Vec<(u32, u128, String)> = host
.hub
.guests
.iter()
.filter(|g| only.map_or(g.placed, |conn| g.conn == conn))
.map(|g| (g.conn, g.uuid, g.name.clone()))
.collect();
for (conn, uuid, name) in players {
let names = host.hub.command_names(conn);
let mut kept = names.clone();
let placed = host.hub.guest(conn).is_some_and(|g| g.placed);
if !placed {
// A joining player has no world yet: nothing can be told them.
self.native_player_commands_sent(uuid, &name, &mut kept);
} else if self.lan_acting == Some(conn) {
self.native_player_commands_sent(uuid, &name, &mut kept);
} else {
self.as_guest(host, conn, |g| {
g.native_player_commands_sent(uuid, &name, &mut kept);
});
}
if let Some(g) = host.hub.guest_mut(conn) {
g.hidden_commands = names.into_iter().filter(|n| !kept.contains(n)).collect();
}
}
}
/// `Hub::resend_commands` with the mods' say on each tree.
pub(super) fn resend_commands_filtered(&mut self) {
let Some(mut host) = self.lan.take() else {
return;
};
host.hub.refresh_permissions();
self.native_filter_commands(&mut host, None);
host.hub.resend_commands();
self.lan = Some(host);
}
/// The level a guest needs for a mod's command (`line`, without its
/// slash), when a mod registered it.
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
@@ -7,11 +7,11 @@ use super::native_world_edit::NativeHeightmap;
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeColumnBlocks {
pub(crate) palette: Vec<String>,
pub(crate) bases: Vec<Option<i32>>,
pub(crate) blocks: Vec<u16>,
pub(crate) tints: Vec<u32>,
pub struct NativeColumnBlocks {
pub palette: Vec<String>,
pub bases: Vec<Option<i32>>,
pub blocks: Vec<u16>,
pub tints: Vec<u32>,
}
#[allow(dead_code)]
@@ -51,8 +51,9 @@ impl Game<'_> {
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
(below, above): (u32, u32),
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
let (below, above) = range;
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
@@ -3,9 +3,8 @@
use super::Game;
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeHeightmap {
pub enum NativeHeightmap {
WorldSurface,
MotionBlocking,
MotionBlockingNoLeaves,
@@ -14,22 +13,22 @@ pub(crate) enum NativeHeightmap {
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeBlockRun {
pub(crate) x: i32,
pub(crate) z: i32,
pub(crate) y: i32,
pub(crate) height: u32,
pub(crate) surface: Option<NativeHeightmap>,
pub(crate) block: u32,
pub(crate) only: Option<u32>,
pub struct NativeBlockRun {
pub x: i32,
pub z: i32,
pub y: i32,
pub height: u32,
pub surface: Option<NativeHeightmap>,
pub block: u32,
pub only: Option<u32>,
}
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
/// by index.
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeBlockEdit {
pub(crate) palette: Vec<String>,
pub(crate) filters: Vec<String>,
pub(crate) runs: Vec<NativeBlockRun>,
pub struct NativeBlockEdit {
pub palette: Vec<String>,
pub filters: Vec<String>,
pub runs: Vec<NativeBlockRun>,
}
#[allow(dead_code)]
@@ -230,6 +230,9 @@ impl Game<'_> {
return false;
}
}
if self.hanging_place_cancelled("minecraft:painting", pos) {
return true;
}
let (lo, hi) = candidate.bounds();
let at = [
(lo[0] + hi[0]) / 2.0,
+23 -24
View File
@@ -2230,7 +2230,7 @@ impl Game<'_> {
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
self.mob_index.get(&self.mobs, id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
@@ -2448,9 +2448,8 @@ impl Game<'_> {
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
let mob = self
.mobs
.iter_mut()
.find(|m| m.id == id as u32)
.mob_index
.get_mut(&mut self.mobs, id as u32)
.filter(|_| !dragon);
let tracked = self
.remote
@@ -2852,7 +2851,7 @@ impl Game<'_> {
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
@@ -3284,7 +3283,7 @@ impl Game<'_> {
None => Some(Holder::Mob(d as u32)),
},
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
m.extra.leash = holder;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
// A leashed boat or raft (AbstractBoat is Leashable).
@@ -3304,7 +3303,7 @@ impl Game<'_> {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
@@ -3337,7 +3336,7 @@ impl Game<'_> {
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
@@ -3350,7 +3349,7 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
@@ -3382,7 +3381,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.extra.ints[1] = code;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
@@ -3401,14 +3400,14 @@ impl Game<'_> {
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
@@ -3518,7 +3517,7 @@ impl Game<'_> {
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
@@ -3571,7 +3570,7 @@ impl Game<'_> {
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
@@ -3753,7 +3752,7 @@ impl Game<'_> {
pitch,
);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
@@ -3766,7 +3765,7 @@ impl Game<'_> {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
@@ -3810,20 +3809,20 @@ impl Game<'_> {
self.fox_eat_particles(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
@@ -3835,7 +3834,7 @@ impl Game<'_> {
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.health = 0.0;
}
@@ -3859,12 +3858,12 @@ impl Game<'_> {
);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
@@ -3941,7 +3940,7 @@ impl Game<'_> {
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
@@ -5219,7 +5218,7 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
@@ -487,7 +487,7 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -170,7 +170,7 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
@@ -286,7 +286,7 @@ impl Game<'_> {
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
{
m.previous = at;
m.position = at;
+21 -10
View File
@@ -258,7 +258,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
self.options.split_guest || self.split_hud.menu,
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
@@ -272,10 +272,15 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in
pause_icons(w, h, links, additions.is_some(), self.options.split_guest)
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -391,7 +396,13 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(w, h, links, additions.is_some(), self.options.split_guest);
let icons = pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
);
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(
w,
@@ -399,7 +410,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
@@ -439,7 +450,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
self.options.split_guest || self.split_hud.menu,
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_view().shown;
@@ -487,7 +498,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
self.options.split_guest || self.split_hud.menu,
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
@@ -500,7 +511,7 @@ impl Game<'_> {
h,
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
@@ -128,6 +128,17 @@ pub(super) struct LevelTeleport {
pub rotation: Option<[f32; 2]>,
}
/// A teleport a command made inside a level: told to the mods once it has
/// reached the hub (`native_player_teleported`).
pub(super) struct TeleportNote {
pub conn: u32,
pub uuid: u128,
pub name: String,
pub from: [f64; 3],
pub to: [f64; 3],
pub dimension: String,
}
/// A player's box: Player's standing dimensions.
const WIDTH: f64 = 0.6;
const HEIGHT: f64 = 1.8;
@@ -708,6 +719,29 @@ impl Game<'_> {
self.set_portal_cooldown(id);
}
/// Tells the mods of the teleports a command made inside a level, with
/// each player's state swapped in: where the move has reached the hub.
pub(super) fn flush_teleport_notes(&mut self, host: &mut LanHost) {
for note in std::mem::take(&mut self.teleport_notes) {
let TeleportNote {
conn,
uuid,
name,
from,
to,
dimension,
} = note;
// In place when it is their turn already (as_guest finds them swapped in).
if self.lan_acting == Some(conn) {
self.native_player_teleported(uuid, &name, from, to, &dimension);
} else {
self.as_guest(host, conn, |g| {
g.native_player_teleported(uuid, &name, from, to, &dimension);
});
}
}
}
/// The players the command just run sends to another level
/// (`ServerPlayer.teleport` from TeleportCommand): a Respawn packet
/// keeping all data and the teleport, with no portal sound. The server
@@ -725,6 +759,7 @@ impl Game<'_> {
log::error!("could not open {}: {e}", t.dim.id());
continue;
}
let (uuid, name) = (host.hub.guests[i].uuid, host.hub.guests[i].name.clone());
let g = &mut host.hub.guests[i];
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
let origin = g.position;
@@ -735,7 +770,12 @@ impl Game<'_> {
let ctx = self.server_ctx();
host.hub
.change_level(i, t.at, yaw, Some(pitch), false, cooldown, &ctx);
self.as_guest(host, t.conn, |g| g.trigger_dimension(source, t.dim, origin));
self.as_guest(host, t.conn, |g| {
g.trigger_dimension(source, t.dim, origin);
// The mods hear the teleport into another level, with the
// player's state swapped in.
g.native_player_teleported(uuid, &name, origin, t.at, t.dim.id());
});
self.server_trips.retain(|trip| !trip.is(t.conn));
self.server_trips.push(Trip {
conn: t.conn,
+11 -10
View File
@@ -32,7 +32,7 @@ use super::{
start_game,
};
use horizon_client::gamepad::Button;
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, View};
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, Spot, View};
use horizon_client::{Result, gfx::compositor::Compositor, widgets};
use std::{
cell::RefCell,
@@ -52,6 +52,7 @@ use winit::{
mod hud;
mod pads;
mod players;
mod setup;
mod windows;
pub(super) use hud::Hud;
@@ -82,14 +83,6 @@ impl Game<'_> {
const WINDOW_1: &str = "Window 1";
const NEW_WINDOW: &str = "New window";
/// A place offered while picking a profile.
#[derive(Clone, PartialEq)]
enum Spot {
Window(u8),
New,
Fill(String),
}
struct Guest<'a> {
game: Box<Game<'a>>,
/// The profile it plays as; `None` while it picks one.
@@ -125,6 +118,8 @@ pub(super) struct Split<'a> {
p1_window: u8,
/// Seats in the order their views fill a shared window.
order: Vec<usize>,
/// Seats represented by the open Setup draft, to detect join/leave changes.
setup_seats: Vec<usize>,
/// Whether Window 1 is shown (hidden while nobody plays in it): set once per change, since
/// Wayland can't say whether a window is visible.
window1_shown: bool,
@@ -179,6 +174,7 @@ impl<'a> Split<'a> {
}],
p1_window: 1,
order: Vec::new(),
setup_seats: Vec::new(),
window1_shown: true,
window_rects: Default::default(),
screens: Vec::new(),
@@ -410,6 +406,7 @@ impl<'a> Split<'a> {
}
};
self.host.renderer = Some(renderer);
self.host.split_window = !self.host.options.offscreen;
self.host.options.offscreen = true;
self.host.options.paced_by_split = true;
// The new renderer takes the old one's present mode, settings and all: P1's again.
@@ -437,6 +434,7 @@ impl<'a> Split<'a> {
}
self.applied.clear();
self.order.clear();
self.setup_seats.clear();
if !watching {
self.stop_routing();
}
@@ -479,6 +477,7 @@ impl<'a> Split<'a> {
drop(old);
self.host.renderer = Some(renderer);
self.host.options.offscreen = false;
self.host.split_window = false;
self.host.options.paced_by_split = false;
// Alone again: the pointer is P1's, its view the whole window.
self.focus = 0;
@@ -705,7 +704,7 @@ impl ApplicationHandler for Split<'_> {
self.screens_read = Some(Instant::now());
self.refresh_monitors();
}
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
self.host.split_hud.menu = self.guests.iter().any(|g| g.name.is_some());
// Off, or the last other player gone: P1 draws on its window again.
if !self.surfaces.is_empty()
&& (!on || self.guests.is_empty())
@@ -714,6 +713,7 @@ impl ApplicationHandler for Split<'_> {
log::warn!("Horizon: splitscreen could not switch back: {e}");
}
if self.surfaces.is_empty() {
self.sync_setup();
if !on {
self.stop_routing();
return self.host.about_to_wait(event_loop);
@@ -740,6 +740,7 @@ impl ApplicationHandler for Split<'_> {
let mut changed = self.drop_out() | self.admit(event_loop, pad_joins);
self.sync_profiles();
changed |= self.pick_profiles();
changed |= self.sync_setup();
changed |= self.sync_surfaces(event_loop);
if changed || self.views() != self.applied {
self.relayout(event_loop);
+8 -2
View File
@@ -9,6 +9,7 @@ use std::time::Duration;
/// moment, with why), a question its controller answers, and notices nobody has to answer.
#[derive(Default)]
pub(in crate::game) struct Hud {
pub(in crate::game) menu: bool,
pub(super) badge: Option<String>,
pub(super) place: Option<(String, Instant)>,
pub(super) prompt: Option<Prompt>,
@@ -77,9 +78,14 @@ pub(super) fn lost_prompt(name: &str) -> Prompt {
}
impl Game<'_> {
/// Another player's pause menu opens its profiles to switch identity.
/// P1 opens Setup; another player opens profiles to switch identity.
pub(in crate::game) fn open_split_settings(&mut self) {
self.open_options_at(false, horizon_client::options_ui::Page::SplitProfiles);
let page = if self.options.split_guest {
horizon_client::options_ui::Page::SplitProfiles
} else {
horizon_client::options_ui::Page::SplitSetup
};
self.open_options_at(false, page);
}
/// Splitscreen over this player's view, above the other toasts (`ToastManager`'s slots).
@@ -330,6 +330,7 @@ impl Split<'_> {
guest.window = Some(window);
guest.world_renderer_ready = true;
guest.keyboard_mouse = false;
guest.split_window = host.split_window;
guest.apply_settings();
// Its pad comes from the split (`route_pads`): it never opens the controllers itself.
guest.pad.feed = Some(PadState::default());
@@ -0,0 +1,155 @@
//! P1's Setup screen's side in the game: the arrangement as a draft, and Apply.
use super::*;
impl<'a> Split<'a> {
/// The arrangement now, for the Setup screen: free windows where they are.
pub(super) fn draft(&self) -> layout::Draft {
// Wayland never says where a window is: those are shown at their real size, side by side
// from the first monitor's corner, so the map never stacks them.
let mut shelf = self.monitors().first().map_or(0, |(_, m)| m.position().x);
let windows = self
.windows
.iter()
.map(|w| {
let here = self.surfaces.iter().find(|s| s.key == w.number);
let placement = match (&w.placement, here) {
(Placement::Free(_), Some(s)) => {
// On the desktop as windows are placed: macOS in points.
let k = if cfg!(target_os = "macos") {
s.window.scale_factor()
} else {
1.0
};
let at = match s.window.outer_position() {
Ok(at) => [(f64::from(at.x) / k) as i32, (f64::from(at.y) / k) as i32],
Err(_) => {
let y = self.monitors().first().map_or(0, |(_, m)| m.position().y);
let at = [shelf, y];
shelf += (f64::from(s.size.width) / k) as i32 + 16;
at
}
};
Placement::Free(Some((at, [s.size.width, s.size.height])))
}
(p, _) => p.clone(),
};
layout::Window {
number: w.number,
placement,
}
})
.collect();
layout::Draft {
windows,
players: (0..self.players()).map(|i| self.window_number(i)).collect(),
two: 0,
three: 0,
order: self.player_order(),
}
}
/// The monitors for the Setup screen, left to right (as `monitor_label` numbers them), and
/// the one Window 1 is on.
pub(super) fn setup_monitors(&self) -> (Vec<layout::Monitor>, String) {
let monitors = self
.monitors()
.iter()
.map(|(name, m)| layout::Monitor {
name: name.clone(),
at: [m.position().x, m.position().y],
size: [m.size().width, m.size().height],
scale: m.scale_factor(),
})
.collect();
(monitors, self.main_monitor().cloned().unwrap_or_default())
}
/// The Setup screen applied: its windows, and every player in its window. Windows nobody
/// plays in close (`forget_empty_windows`).
pub(super) fn apply(&mut self, draft: layout::Draft) {
let mut windows: Vec<layout::Window> = draft
.windows
.into_iter()
.map(|w| {
// A free window stays where it is; a new one opens where the system puts it.
let placement = match (&w.placement, self.placement(w.number)) {
(Placement::Free(_), Some(Placement::Free(p))) => Placement::Free(*p),
(Placement::Free(_), _) => Placement::Free(None),
(p, _) => p.clone(),
};
layout::Window {
number: w.number,
placement,
}
})
.collect();
if !windows.iter().any(|w| w.number == 1) {
windows.insert(
0,
layout::Window {
number: 1,
placement: Placement::Free(None),
},
);
}
self.windows = windows;
// Players who joined while it was open stay where they are.
for (i, &number) in draft.players.iter().enumerate().take(self.players()) {
let number = if self.placement(number).is_some() {
number
} else {
1
};
self.set_window(i, number);
}
// Who is first in a shared window, by seat (players are by index in the draft).
let n = self.players();
self.order = layout::ordered(&draft.order, n)
.into_iter()
.map(|i| self.seat_of(i))
.collect();
}
/// P1's Setup screen: the arrangement now, the monitors and the players' names; Apply
/// arranges them. True when it did.
pub(super) fn sync_setup(&mut self) -> bool {
use horizon_client::options_ui::Page;
if self.host.ui.options.page() != Page::SplitSetup {
return false;
}
// Window/fullscreen changes during P1's turn can invalidate Windows monitor handles.
self.refresh_monitors();
let live = self.draft();
let (monitors, main) = self.setup_monitors();
let names = (0..self.players()).map(|i| self.name(i)).collect();
let seats: Vec<usize> = (0..self.players()).map(|i| self.seat_of(i)).collect();
let moved = std::mem::replace(&mut self.setup_seats, seats) != self.setup_seats;
let setup = &mut self.host.ui.options.split_setup;
// Someone joined or left: the draft would put the players in the wrong places.
if moved {
setup.reset();
}
let applied = setup.apply.take();
let setup = &mut self.host.ui.options.split_setup;
setup.idle = self.surfaces.is_empty();
setup.live = Some(live);
setup.monitors = monitors;
setup.main = main;
setup.names = names;
let changed = applied.is_some();
if let Some(draft) = applied {
self.apply(draft);
}
// Every view says where it is: after a change, or asked.
if changed {
for i in 0..self.players() {
let label = horizon_client::lang::tr(
"horizon.splitscreen.setup.window",
&[&self.window_of(i)],
);
self.tag(i, &label);
}
}
changed
}
}
@@ -65,9 +65,13 @@ pub(super) fn above_menu_bar(title: &str) {
}
}
/// Player `game` into `window`; when it `moved` from another, the mouse it held there goes free
/// (a click takes it in the new one).
pub(super) fn into_window(game: &mut Game<'_>, window: Arc<Window>, moved: bool) {
/// Move a player into a native window. Changing only its viewport keeps the grab;
/// moving to another window releases it until a click there.
pub(super) fn into_window(game: &mut Game<'_>, window: Arc<Window>) {
let moved = game
.window
.as_ref()
.is_none_or(|old| old.id() != window.id());
if moved && game.captured {
game.capture(false);
}
@@ -311,9 +315,12 @@ impl<'a> Split<'a> {
let Some(window) = self.surfaces.get(surface).map(|s| s.window.clone()) else {
continue;
};
let moved = before.get(i).is_none_or(|b| b.0 != surface);
let moved = self
.game(i)
.and_then(|g| g.window.as_ref())
.is_none_or(|old| old.id() != window.id());
if let Some(game) = self.game_mut(i) {
into_window(game, window.clone(), moved);
into_window(game, window.clone());
game.options.split_in_main_window = surface == 0;
game.view_at = at;
}
+28
View File
@@ -128,6 +128,31 @@ impl PrimedTnt {
}
impl Game<'_> {
/// Asks the mods whether the TNT at `pos` may be primed
/// (`native_tnt_primed`); the acting guest, if any, is its player.
pub(super) fn tnt_prime_allowed(&mut self, pos: BlockPos) -> bool {
let mut cancel = false;
self.native_tnt_primed(self.lan_acting_uuid, [pos.x, pos.y, pos.z], &mut cancel);
!cancel
}
/// Primes TNT a level event announced, or, when the mods refuse, puts
/// back the block the fire or signal had already taken out.
pub(super) fn prime_tnt_event(&mut self, pos: BlockPos, rot: f64) {
if self.tnt_prime_allowed(pos) {
self.add_primed_tnt(pos, rot, 80, None, None);
return;
}
// Refused: the level queued the fuse sound and the game event right
// after the prime; they are dropped as they come up.
self.tnt_refused = Some(pos);
let Some(tnt) = self.pack.assets.registry.default_state("minecraft:tnt") else {
return;
};
if self.world.block(pos.x, pos.y, pos.z) == self.world.air {
let flags = level_flags::UPDATE_CLIENTS | level_flags::UPDATE_SKIP_ON_PLACE;
self.with_level(|b, level, ticks| b.set_block(level, ticks, pos, tnt, flags));
}
}
/// `new PrimedTnt(level, x + 0.5, y, z + 0.5, owner)`; `short` applies
/// `wasExploded`'s random short fuse. Plays `entity.tnt.primed` for primes.
pub(super) fn spawn_primed_tnt(
@@ -263,6 +288,9 @@ impl Game<'_> {
return Ok(false);
}
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
if !self.tnt_prime_allowed(pos) {
return Ok(true);
}
let guest = self.lan_acting;
self.spawn_primed_tnt(pos, false, Some(Who::Player), guest);
let air = self.world.air;
+6 -2
View File
@@ -29,10 +29,14 @@ impl Game<'_> {
/// count it, set health to 1, then clear and apply its effects. Returns
/// the player's body, for entity event 35, which the caller broadcasts.
pub(super) fn apply_totem(&mut self) -> Option<Body> {
let p = self.player.as_mut()?;
if !p.health.totem_used {
if !self.player.as_ref()?.health.totem_used {
return None;
}
// The mods hear a server player's totem before it is used up.
if let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_totem(uuid, &name);
}
let p = self.player.as_mut()?;
p.health.totem_used = false;
let _ = p.set_health(1.0, false);
let body = Body {
@@ -388,6 +388,10 @@ pub(super) fn preload_world_data(
// seed shares: about 140 ms off opening the world.
let _ = data.prepare_decoration("minecraft:normal", "minecraft:overworld");
}
// The loot data's item tables and the server's data both
// read the recipe folder: held here, it is read once.
let _recipe_files =
horizon_client::json_folder::read(&resources.join("data/minecraft/recipe"));
// The preload is the game's own data; the world's packs'
// tables are built when it opens (finish_open_world).
let _ = horizon_client::structure_blocks::loot_data(
@@ -459,6 +463,10 @@ pub(super) fn preload_launch_world(
let preset = horizon_client::save::WorldGen::preset(&world_root);
let _ = data.prepare_decoration(preset, "minecraft:overworld");
}
// As in `preload_world_data`: the loot data and the server's
// data share one read of the recipe folder.
let _recipe_files =
horizon_client::json_folder::read(&resources.join("data/minecraft/recipe"));
let _ = horizon_client::structure_blocks::loot_data(
&resources,
&horizon_world::loot::PackOverlay::NONE,
+4
View File
@@ -208,6 +208,10 @@ impl SyncData {
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
) -> Result<Self, String> {
// The recipe book, the recipe sync and the advancements read the
// recipe folder: held here, they share one read of it.
let _recipe_files =
horizon_world::json_folder::read(&resources.join("data/minecraft/recipe"));
let builtin = Builtin::find(resources)
.and_then(|p| Builtin::load(&p).ok())
.ok_or("registries.json not found next to the resources")?;
+4
View File
@@ -296,6 +296,9 @@ pub struct Guest {
pub death_time: u32,
/// The join packets were sent (PlayerList.placeNewPlayer ran).
pub placed: bool,
/// Root command names the mods took out of this player's command tree
/// (`native_player_commands_sent`); their tree is sent without them.
pub hidden_commands: Vec<String>,
/// `Abilities.flyingSpeed`: vanilla's 0.05, unless a server mod set
/// another (`native_set_flying_speed`). Saved with the player.
pub flying_speed: f32,
@@ -456,6 +459,7 @@ impl Guest {
dead: false,
death_time: 0,
placed: false,
hidden_commands: Vec::new(),
flying_speed: 0.05,
dimension: "minecraft:overworld".into(),
menu: None,
+61 -6
View File
@@ -1162,14 +1162,42 @@ impl Hub {
/// The permissions changed: tell everyone their level and commands again.
pub fn resend_commands(&mut self) {
self.refresh_permissions();
for i in 0..self.guests.len() {
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
if self.guests[i].placed {
self.send_permission_level(i);
}
}
}
/// Every guest's level from the operator list and the settings, the first
/// half of [`Hub::resend_commands`].
pub fn refresh_permissions(&mut self) {
for i in 0..self.guests.len() {
self.guests[i].permission = self.default_permission(self.guests[i].uuid);
}
}
/// The names of the root commands the tree `conn`'s player would get
/// holds (before the mods take any out), in the tree's order.
pub fn command_names(&self, conn: u32) -> Vec<String> {
let Some(g) = self.guest(conn) else {
return Vec::new();
};
let packet = self.commands_packet(g.permission);
packet
.nodes
.get(packet.root as usize)
.map(|root| {
root.children
.iter()
.filter_map(|&c| packet.nodes.get(c as usize))
.map(|n| n.name.clone())
.collect()
})
.unwrap_or_default()
}
/// Whether vanilla's command tree has a command of this name.
pub fn vanilla_command(&self, name: &str) -> bool {
self.sync.commands.as_ref().is_some_and(|t| t.has(name))
@@ -1196,11 +1224,31 @@ impl Hub {
/// Commands.sendCommands: the command tree for this player.
fn send_commands(&self, i: usize) {
let g = &self.guests[i];
let packet = match &self.sync.commands {
let mut packet = self.commands_packet(g.permission);
// What the mods took out is not in the tree the client gets.
if !g.hidden_commands.is_empty() {
let hidden: Vec<i32> = packet
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.kind == 1 && g.hidden_commands.contains(&n.name))
.map(|(i, _)| i as i32)
.collect();
let root = packet.root as usize;
if let Some(root) = packet.nodes.get_mut(root) {
root.children.retain(|c| !hidden.contains(c));
}
}
g.send(&C::Commands(packet));
}
/// The command tree a player of this permission level gets.
fn commands_packet(&self, permission: u8) -> play::Commands {
match &self.sync.commands {
Some(tree) => {
let dedicated = self.config.dedicated && !self.config.integrated;
tree.packet_with(
g.permission,
permission,
dedicated,
&self.sync.builtin,
&self.mod_commands.0,
@@ -1219,8 +1267,7 @@ impl Hub {
}],
root: 0,
},
};
g.send(&C::Commands(packet));
}
}
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
@@ -2976,7 +3023,15 @@ impl Hub {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
}
S::PlayerCommand(p) => match p.action {
1 | 2 => self.set_sprinting(i, p.action == 1),
1 | 2 => {
let sprinting = p.action == 1;
let changed = self.guests[i].sprinting != sprinting;
self.set_sprinting(i, sprinting);
// The game tells the mods of a change.
if changed {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
}
}
// STOP_SLEEPING.
0 => self.wake(i),
_ => {}
+11 -12
View File
@@ -52,17 +52,16 @@ fn ingredient(v: &Value) -> Option<Ingredient> {
}
}
/// Every recipe file, by recipe id (the registry's order).
pub(super) fn recipes(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Value>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
recipes(&path, &format!("{prefix}{name}/"), out);
} else if let Some(stem) = name.strip_suffix(".json")
&& let Ok(v) = serde_json::from_slice(&std::fs::read(&path).unwrap_or_default())
{
out.insert(format!("minecraft:{prefix}{stem}"), v);
/// Every recipe file, by recipe id (the registry's order): read through
/// `json_folder`, so a load holding the folder shares one read of it. A file
/// that can't be read or doesn't parse is left out.
pub(super) fn recipes(dir: &Path, out: &mut BTreeMap<String, Value>) {
let Ok(folder) = horizon_world::json_folder::read(dir) else {
return;
};
for file in &folder.files {
if let Ok(v) = serde_json::from_slice(file.bytes().unwrap_or_default()) {
out.insert(format!("minecraft:{}", file.name), v);
}
}
}
@@ -94,7 +93,7 @@ pub(super) fn network_recipes(
overlay: &horizon_world::loot::PackOverlay,
) -> (BTreeMap<String, Box<str>>, HashMap<String, i32>) {
let mut all = BTreeMap::new();
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
recipes(&resources.join("data/minecraft/recipe"), &mut all);
overlay_recipes(overlay, &mut all);
// The data's recipe filters, as the server's own tables apply them
// (#5910), once, before the ingredient normalization, so a swapped
+26 -1
View File
@@ -406,7 +406,7 @@ pub fn native_menu_slot_rules(
may_place: &mut bool,
max_stack: &mut u32,
) {
let _ = (menu, slot, stack, may_place, max_stack);
crate::native_client_hooks::menu_slot_rules(menu, slot, stack, may_place, max_stack);
}
/// An index outside the slot list: vanilla's `IndexOutOfBoundsException`.
@@ -3459,6 +3459,31 @@ pub(crate) mod tests {
});
}
#[test]
fn installed_slot_rules_change_mod_menu_placement() {
let installed =
crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, _, may_place, max_stack| {
if menu == "test:rules" && slot == 0 {
*may_place = false;
*max_stack = 7;
}
},
living_breathe: |_, _, _, _, _| {},
ingredient: |_, _, _| {},
});
assert!(installed.is_ok());
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
id: "test:rules",
slots: 1,
data_slots: 0,
results: vec![],
})));
let menu = Menu::new(kind);
let stack = ItemStack::new("minecraft:stone", 1);
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
}
/// A mod menu type: three slots, then a result (slot 3).
fn mod_menu() -> Kind {
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+1
View File
@@ -73,6 +73,7 @@ pub mod render;
pub mod riders;
pub mod seen_grid;
pub mod shulker;
pub mod skin;
pub mod sniffer;
pub mod spawn_rules;
pub mod spawn_selection;
+39 -26
View File
@@ -2,6 +2,7 @@
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
//! part poses before the model is drawn.
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::geometry::trig::Mth;
use std::collections::HashMap;
use std::f32::consts::{FRAC_PI_6, PI};
@@ -101,15 +102,27 @@ impl AnimState {
}
pub fn end_tick(&mut self, _swinging: bool) {}
}
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
#[inline]
pub(crate) fn key(name: &str) -> u64 {
use std::hash::Hasher;
let mut h = crate::fast_hash::FxHasher::default();
h.write(name.as_bytes());
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
h.write_usize(name.len());
h.finish()
}
/// Part poses for one draw, keyed by part name.
#[derive(Clone, Default)]
pub struct Poses {
map: HashMap<String, [f32; 9]>,
map: FxHashMap<u64, [f32; 9]>,
dummy: [f32; 9],
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
skipped: std::collections::HashSet<String>,
skipped: FxHashSet<u64>,
/// Parts with `visible` cleared: neither they nor their children draw.
hidden: std::collections::HashSet<String>,
hidden: FxHashSet<u64>,
}
impl Poses {
pub fn new(model: &Part) -> Self {
@@ -120,9 +133,9 @@ impl Poses {
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
map.entry(p.name.clone()).or_insert(p.pose);
map.entry(key(&p.name)).or_insert(p.pose);
for c in &p.children {
walk(c, map);
}
@@ -134,26 +147,26 @@ impl Poses {
walk(model, &mut self.map);
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
match self.map.get_mut(name) {
match self.map.get_mut(&key(name)) {
Some(p) => p,
None => &mut self.dummy,
}
}
pub fn pose(&self, part: &Part) -> [f32; 9] {
self.map.get(&part.name).copied().unwrap_or(part.pose)
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
}
pub fn skip_draw(&mut self, name: &str) {
self.skipped.insert(name.to_owned());
self.skipped.insert(key(name));
}
pub fn skips(&self, part: &Part) -> bool {
self.skips_named(&part.name)
}
/// `skips` by the part's name.
pub fn skips_named(&self, name: &str) -> bool {
self.skipped.contains(name)
!self.skipped.is_empty() && self.skipped.contains(&key(name))
}
pub fn has(&self, name: &str) -> bool {
self.map.contains_key(name)
self.map.contains_key(&key(name))
}
pub fn xrot(&mut self, n: &str, v: f32) {
self.get(n)[3] = v;
@@ -167,9 +180,9 @@ impl Poses {
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
pub fn visible(&mut self, n: &str, v: bool) {
if v {
self.hidden.remove(n);
} else if self.map.contains_key(n) {
self.hidden.insert(n.to_owned());
self.hidden.remove(&key(n));
} else if self.map.contains_key(&key(n)) {
self.hidden.insert(key(n));
}
}
pub fn is_hidden(&self, part: &Part) -> bool {
@@ -177,7 +190,7 @@ impl Poses {
}
/// `is_hidden` by the part's name.
pub fn is_hidden_named(&self, name: &str) -> bool {
self.hidden.contains(name)
!self.hidden.is_empty() && self.hidden.contains(&key(name))
}
}
/// The LivingEntityRenderState values setupAnim reads.
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
};
player_model_visibility(&mut p, 0, true);
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
assert!(p.hidden.contains(part), "{part}");
assert!(p.hidden.contains(&key(part)), "{part}");
}
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
// Back in another mode they all show, but for the layers the player hid.
player_model_visibility(&mut p, 1 << 6, false);
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
assert!(p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
assert!(p.hidden.contains(&key("hat")));
}
#[test]
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
"body",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
+326 -65
View File
@@ -1278,15 +1278,7 @@ fn draw_normals(
out: &mut Vec<Vertex>,
) {
let p = poses.pose(part);
let m = base
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
let m = posed(base, p);
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
return;
}
@@ -1324,6 +1316,46 @@ fn draw_normals(
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
}
}
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
#[allow(clippy::too_many_arguments)]
fn put(
skin: &mut Option<&mut super::skin::Skin>,
part: &std::sync::Arc<Part>,
base: glam::Mat4,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
let paint = super::skin::Paint {
uv,
scroll,
color,
light,
overlay,
};
if let Some(skin) = skin
&& skin.draw(part, base, poses, paint)
{
return;
}
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
}
/// Render a mob with its variant model, texture and layers.
pub fn render(
m: &Mob,
@@ -1360,7 +1392,7 @@ pub fn render_with(
let shifted =
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
let base = base_transform(m, alpha, shifted);
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
}
}
return;
@@ -1375,6 +1407,37 @@ pub fn render_with(
extra,
true,
out,
None,
);
}
/// `render` with the model's ordinary draws posed on the GPU when the mob
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
#[allow(clippy::too_many_arguments)]
pub fn render_skinned(
m: &Mob,
alpha: f32,
camera: glam::DVec3,
atlas: Atlas,
light: u32,
data: &MobData,
out: &mut Vec<Vertex>,
skin: &mut super::skin::Skin,
) {
if super::trader::is_invisible(m) {
render_with(m, alpha, camera, atlas, light, data, &[], out);
return;
}
render_visible(
m,
alpha,
base_transform(m, alpha, camera),
atlas,
light,
data,
&[],
true,
out,
Some(skin),
);
}
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
@@ -1632,7 +1695,7 @@ pub fn render_layers(
out: &mut Vec<Vertex>,
) {
let base = base_transform(m, alpha, camera);
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
}
/// A mob drawn under `origin` instead of at its position (the spinning mob
/// of `SpawnerRenderer.submitEntityInSpawner`).
@@ -1646,7 +1709,7 @@ pub fn render_posed(
out: &mut Vec<Vertex>,
) {
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
}
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
fn cem_key(model_key: &str) -> &str {
@@ -1730,6 +1793,7 @@ fn render_visible(
extra: &[(&Part, [f32; 4], u32, u32, bool)],
body_visible: bool,
out: &mut Vec<Vertex>,
skin: Option<&mut super::skin::Skin>,
) {
let overlay = u32::from(red_overlay(m));
let s = anim::state(m, alpha);
@@ -1799,56 +1863,6 @@ fn render_visible(
if let Some(animation) = body_animation {
animation.run(m, &s, &mut poses);
}
let draw_body = |out: &mut Vec<Vertex>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
draw(
&model,
base,
&poses,
uv,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
draw(
&model,
base,
&poses,
glow,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
let mut all_layers = layers(m, s.age);
// WardenRenderer: the translucent emissive layers fade with the animations.
if m.species() == Species::Warden {
@@ -1864,6 +1878,87 @@ fn render_visible(
}
}
}
// The GPU path takes the whole mob or none of it, so its draws keep their
// order: only plain opaque and cutout draws of models it can pose (no
// custom models, translucency, eyes or energy swirl, which the CPU path
// changes after drawing).
let mut skin = skin;
let posable = match skin.as_deref_mut() {
Some(skin) => {
custom_body.is_none()
&& extra.is_empty()
&& m.species() != Species::SulfurCube
&& !translucent_body(m)
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
.is_none_or(|part| skin.supports(part))
})
}
None => false,
};
if !posable {
skin = None;
}
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
put(
skin,
&model,
base,
&poses,
uv,
0.0,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
put(
skin,
&model,
base,
&poses,
glow,
0.0,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out, &mut skin);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
@@ -1917,7 +2012,8 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1929,7 +2025,8 @@ fn render_visible(
out,
);
if let Some(glow) = emissive_uv(drawn) {
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1979,7 +2076,7 @@ fn render_visible(
}
}
if shell_last {
draw_body(out);
draw_body(out, &mut skin);
}
}
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
@@ -3084,6 +3181,170 @@ mod scroll_tests {
]
);
}
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
/// different names never share one (they would share a pose).
#[test]
fn no_two_part_names_share_a_key() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
out.push(&p.name);
for c in &p.children {
names(c, out);
}
}
let types = super::super::MobTypes::load(&root).unwrap();
let mut models = 0;
for kind in types.types.values() {
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
let mut all = Vec::new();
names(model, &mut all);
let mut keys = std::collections::HashMap::new();
for name in all {
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
assert_eq!(other, name, "{} shares a key", kind.id);
}
models += 1;
}
}
assert!(models > 100);
}
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
#[test]
#[ignore = "manual benchmark; needs the prepared resources"]
fn benchmark_mob_farm_geometry() {
use std::{hash::Hasher, hint::black_box, time::Instant};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
let kinds = [
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
]
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
let mobs: Vec<Mob> = (0..1000u32)
.map(|i| {
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
let at = horizon_core::geometry::Vec3::new(
-14.25 + 1.5 * f64::from(col),
1.0 + 2.5 * f64::from(layer),
8.0 + 2.0 * f64::from(row),
);
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
m.previous = at;
m.age = i;
m
})
.collect();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let atlas = |name: &str| {
let h = name.len() as f32 / 64.0;
Some([h, h, h + 0.25, h + 0.25])
};
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
let mut out = Vec::new();
let frame = |out: &mut Vec<Vertex>| {
out.clear();
for m in &mobs {
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
}
};
frame(&mut out);
let mut hash = std::collections::hash_map::DefaultHasher::new();
hash.write(bytemuck::cast_slice(&out));
// The thread's own CPU time where the platform gives it, else wall time.
let timed = |f: &mut dyn FnMut()| {
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
f();
match (horizon_client_render::spans::thread_cpu(), cpu) {
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
_ => wall.elapsed().as_secs_f64() * 1000.0,
}
};
let mut times = Vec::new();
for _ in 0..30 {
times.push(timed(&mut || frame(black_box(&mut out))));
}
times.sort_by(f64::total_cmp);
println!(
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
out.len(),
times[15],
times[0],
hash.finish()
);
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
// body's vertices alone from those poses.
let median = |mut f: Box<dyn FnMut() + '_>| {
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
t.sort_by(f64::total_cmp);
t[15]
};
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
.iter()
.map(|m| {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
(model, poses)
})
.collect();
let poses_ms = median(Box::new(|| {
for m in &mobs {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
black_box(&poses);
}
}));
let mut body = Vec::new();
let draw_ms = median(Box::new(|| {
body.clear();
for (m, (model, poses)) in mobs.iter().zip(&posed) {
let base = base_transform(m, 0.5, camera);
draw(
model,
base,
poses,
[0.0, 0.0, 0.25, 0.25],
u32::MAX,
0xf0f0,
0,
&mut body,
);
}
black_box(&body);
}));
println!(
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
body.len()
);
}
}
#[cfg(test)]
+422
View File
@@ -0,0 +1,422 @@
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
//! uploaded once, and the walk that turns one `render::draw` into the parts'
//! matrices and jobs the shader expands into the very vertices `draw` would
//! push, in its order.
//!
//! A model takes this path only when every polygon faces an axis: the
//! normal matrix times an axis is one of its columns, exactly, so the shader's
//! packed normals are the CPU's to the bit. Positions and UVs are the same
//! sums in the same order (`Mat4::transform_point3`).
use super::{ModelNormals, Part, anim::Poses};
use crate::fast_hash::FxHashMap;
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
use std::sync::Arc;
/// A stored model: where its vertices start, and per part (pre-order) its
/// own vertices and the end of its subtree, for the walk.
struct Stored {
first: u32,
parts: Vec<StoredPart>,
/// The model's bounds in its parts' own space, per part: min and max of
/// its own vertices (none: no polygons).
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
}
#[derive(Clone, Copy)]
struct StoredPart {
/// The part's first vertex, from the model's.
first: u32,
count: u32,
/// One past the last part of its subtree.
end: u32,
}
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
/// its `Arc`, so the address is never reused while stored; models no one
/// else holds any more are dropped by `begin`.
#[derive(Default)]
pub struct SkinModels {
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
vertices: u32,
}
/// Models whose store would grow past this restart it (a pack reload's
/// models pile up otherwise).
const STORE_LIMIT: u32 = 4 << 20;
impl SkinModels {
/// Starts a frame: drops the models nothing else holds (their vertices
/// stay in the store until it restarts), restarts the store when it grew
/// too large or the renderer's (`stored`, its size) is out of step.
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
self.restart(frame);
}
self.stored
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
frame.clear_draws();
}
fn restart(&mut self, frame: &mut SkinFrame) {
self.stored.clear();
self.vertices = 0;
frame.new_model.clear();
frame.base = 0;
frame.reset = true;
}
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
let key = Arc::as_ptr(model) as usize;
let vertices = &mut self.vertices;
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
// The renderer appends `new_model` to its store in order, so a
// model's first vertex is the store's size when it is added.
let before = frame.new_model.len();
let stored = flatten(model, *vertices, &mut frame.new_model);
*vertices += (frame.new_model.len() - before) as u32;
(model.clone(), stored)
});
stored.as_ref()
}
}
/// The model's vertices in pre-order (a part's polygons, then its children),
/// appended to `out`; `None` when a polygon does not face an axis.
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
let start = out.len();
let mut parts = Vec::new();
let mut bounds = Vec::new();
fn walk(
part: &Part,
start: usize,
out: &mut Vec<SkinVertex>,
parts: &mut Vec<StoredPart>,
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
) -> bool {
let index = parts.len();
let own = (out.len() - start) as u32;
parts.push(StoredPart {
first: own,
count: 0,
end: 0,
});
bounds.push(None);
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for poly in &part.polygons {
let Some(axis) = axis(poly.normal) else {
return false;
};
for v in poly.vertices {
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
for a in 0..3 {
lo[a] = lo[a].min(pos[a]);
hi[a] = hi[a].max(pos[a]);
}
out.push(SkinVertex {
pos,
uv: [v[3], v[4]],
axis,
part: index as u32,
});
}
}
parts[index].count = (out.len() - start) as u32 - own;
if !part.polygons.is_empty() {
bounds[index] = Some((lo, hi));
}
for child in &part.children {
if !walk(child, start, out, parts, bounds) {
return false;
}
}
parts[index].end = parts.len() as u32;
true
}
if !walk(model, start, out, &mut parts, &mut bounds) {
out.truncate(start);
return None;
}
Some(Stored {
first,
parts,
bounds,
})
}
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
fn axis(n: [f32; 3]) -> Option<u32> {
let mut found = None;
for (a, &c) in n.iter().enumerate() {
if c == 1.0 || c == -1.0 {
if found.is_some() {
return None;
}
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
} else if c != 0.0 {
return None;
}
}
found
}
/// One draw's parameters (`render::draw_scrolled`'s).
#[derive(Clone, Copy)]
pub struct Paint {
pub uv: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
}
/// Where a frame's posed draws go, and the bounds of the current mob's.
pub struct Skin<'a> {
pub models: &'a mut SkinModels,
pub frame: &'a mut SkinFrame,
/// Camera-relative bounds of what this mob drew here (for culling).
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
}
impl Skin<'_> {
/// Whether this model can be posed here (stored on first sight).
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
self.models.entry(model, self.frame).is_some()
}
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
/// model cannot take this path.
pub fn draw(
&mut self,
model: &Arc<Part>,
base: glam::Mat4,
poses: &Poses,
paint: Paint,
) -> bool {
let Some(stored) = self.models.entry(model, self.frame) else {
return false;
};
let first_part = self.frame.parts.len() as u32;
self.frame.parts.resize(
self.frame.parts.len() + stored.parts.len(),
bytemuck::Zeroable::zeroed(),
);
let mut walk = Walk {
stored,
poses,
parts: &mut self.frame.parts[first_part as usize..],
jobs: &mut self.frame.jobs,
vertices: &mut self.frame.vertices,
bounds: &mut self.bounds,
first_part,
paint,
index: 0,
};
walk.part(model, base, ModelNormals::new(base));
true
}
}
struct Walk<'a> {
stored: &'a Stored,
poses: &'a Poses,
parts: &'a mut [SkinPart],
jobs: &'a mut Vec<SkinJob>,
vertices: &'a mut u32,
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
first_part: u32,
paint: Paint,
/// The pre-order index of the part being walked.
index: u32,
}
impl Walk<'_> {
/// `render::draw_normals` without the vertices: the part's matrices, and
/// its polygons as (part of) a job.
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
let index = self.index;
let stored = self.stored.parts[index as usize];
let p = self.poses.pose(part);
let m = super::render::posed(base, p);
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
// The whole subtree is left out.
self.index = stored.end;
return;
}
let normals = normals.part(p);
let n = if normals.trusted {
normals.matrix
} else {
glam::Mat3::from_cols(
normals.matrix.x_axis.normalize_or_zero(),
normals.matrix.y_axis.normalize_or_zero(),
normals.matrix.z_axis.normalize_or_zero(),
)
};
self.parts[index as usize] = SkinPart {
matrix: m.to_cols_array(),
normal: n.to_cols_array(),
};
if !self.poses.skips(part) && stored.count > 0 {
self.emit(stored);
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
let center = m.transform_point3((lo + hi) * 0.5);
let half = (hi - lo) * 0.5;
let r = glam::Mat3::from_mat4(m);
let extent =
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
let (a, b) = (center - extent, center + extent);
*self.bounds = Some(match *self.bounds {
Some((l, h)) => (l.min(a), h.max(b)),
None => (a, b),
});
}
}
self.index += 1;
for child in &part.children {
self.part(child, m, normals);
}
}
/// The part's vertices: extends the last job when they follow its own.
fn emit(&mut self, stored: StoredPart) {
let first = self.stored.first + stored.first;
let paint = self.paint;
if let Some(job) = self.jobs.last_mut()
&& job.first + job.count == first
&& job.part == self.first_part
&& job.dst + job.count == *self.vertices
{
job.count += stored.count;
} else {
self.jobs.push(SkinJob {
first,
count: stored.count,
part: self.first_part,
dst: *self.vertices,
rect: paint.uv,
scroll: paint.scroll,
color: paint.color,
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
spare: [0; 3],
});
}
*self.vertices += stored.count;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::entity_skin::expand;
use crate::mobs::{Mob, MobData, MobTypes, render};
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Every texture its own rect, so a swapped texture shows.
let atlas = |name: &str| {
let h = name
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
let u = (h % 61) as f32 / 64.0;
let v = (h / 61 % 61) as f32 / 64.0;
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
};
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
let mut models = SkinModels::default();
let mut frame = SkinFrame::default();
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
}
}
+15 -1
View File
@@ -5,17 +5,28 @@
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
use std::sync::{Arc, OnceLock};
pub use crate::containers::{ModBlockEntity, ModContainer, ModFluidTanks};
pub use crate::biome::BiomeColors;
pub use crate::camera::CameraEntity;
pub use crate::containers::{ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
pub use crate::crafting::{CustomRecipe, Ingredient};
pub use crate::extras::Corner;
pub use crate::extras_hud::Corners;
pub use crate::first_person::Hand;
pub use crate::fog::{FogData, FogType};
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
pub use crate::game::{
NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeDataMapType,
NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit,
NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayerRender,
NativeShake,
};
pub use crate::gfx::native_render::{
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
};
pub use crate::hud::HudBatch;
pub use crate::hud_layout::{GuiDraw, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::music::{Music, MusicLayer};
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
pub use horizon_core::{
@@ -23,18 +34,21 @@ pub use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
pub use horizon_world::consumable::UseAnimation;
pub use horizon_world::{
attachments::{
AttachmentCodec, AttachmentError, AttachmentHost, AttachmentType, Attachments, Holder,
},
fluid::FluidState,
level::{BlockId, Dimension as LevelDimension, PlacementContext},
loot::{LootStack, NativeLootContext},
nbt::{Compound, Tag},
registry::BlockStateId,
};
pub use serde_json;
use serde_json::{Map, Value};
use std::time::Duration;
pub use winit::event::KeyEvent;
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
File diff suppressed because it is too large. Load diff
+161
View File
@@ -237,6 +237,167 @@ pub trait Host {
state: BlockStateId,
flags: i32,
) -> bool;
/// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension`
/// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a
/// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would
/// give it, a mod block's included); no neighbour is told, and the chunks changed go to the players whole.
/// Runs in chunks not loaded, and the parts of runs outside the column, are left out. The blocks changed, or
/// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry
/// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end,
/// or more than 65536 filters. API 126.
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String>;
/// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being
/// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a
/// server, or more than 65,536 columns (`MAX_COLUMNS`). API 127.
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String>;
/// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a
/// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns
/// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the
/// game draws them: near a biome border they can differ from what players see. `Err` says why nothing was
/// read: another dimension or a client of a server, `below + above` of 0 or more than the level's height, or
/// a read of more than 1,048,576 blocks (`MAX_READ`; it runs on the server tick, so read big areas in pieces).
/// API 127.
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String>;
/// Asks `native_biome_colors` again for every biome and, when the colours
/// differ from those in use, rebuilds every loaded section and the far
/// terrain with them (`LevelRenderer.allChanged`, as changing Biome Blend
/// does). Call it when your colours change (a new season, a new day of a
/// slow fade) and once when the player joins, not every frame: a rebuild
/// takes the workers a few seconds. API 107.
fn refresh_biome_colors(&mut self);
/// The strongest redstone signal reaching the block at `pos` from its six
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
/// a client's copy of the world and for another dimension. A mod's
/// explosive, launcher or machine reads it in its block entity's tick to
/// go off or switch on. API 96.
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32;
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
/// A solar panel checks for full sky light above it, a lamp or a mob farm
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
/// reads here it reads the world this game holds: the level in place on
/// a server, the server's copy on a client. API 97.
fn light(&self, pos: [i32; 3]) -> (u8, u8);
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
/// no weather (`Level.canHaveWeather`). API 97.
fn weather(&self) -> (f32, f32);
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
/// take it modulo 24000 for the hour. API 97.
fn day_time(&self) -> i64;
/// The lowest block y and the height of the world in place
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
/// miner may dig. API 97.
fn world_height(&self) -> (i32, i32);
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
/// or `None` for an unknown item: for a mod's container that fills or
/// merges stacks itself. API 97.
fn max_stack_size(&self, item: &str) -> Option<u32>;
/// Item `item`'s icon drawn with model `model` (`minecraft:paper` as
/// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item`
/// draws one; false for an unknown item. API 111.
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool;
/// A quad of corners `points` (in order round it) in GUI pixels, `argb`:
/// a wedge, a slanted bar, what `native_fill`'s rectangles can't draw.
/// API 111.
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32);
/// Opens a screen of mod `mod_id`'s own over the world: 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 there; the mouse is freed, and Escape closes it. Every mod's own
/// screen has the `NativeScreenView.id` `mod`, so a mod's screen hooks
/// check `native_mod_screen()` is its own before drawing. It doesn't pause
/// a single-player game (as the inventory doesn't). True when it is open:
/// false while another screen is (or another mod's), over the title
/// screen, a book, a sign editor, a dialog or a prompt, while the world
/// loads, and anywhere but the window's own client (the server thread, a
/// dedicated server). A gamepad can close it (B, Start) but not click on
/// it: its clicks don't reach the mouse hooks. API 111.
fn mod_screen_open(&mut self, mod_id: &str) -> bool;
/// Closes mod `mod_id`'s own screen, if it is open. API 111.
fn mod_screen_close(&mut self, mod_id: &str);
/// The mod whose own screen is open, if one is (Escape, or another screen
/// replacing it, closes it). API 111.
fn mod_screen(&self) -> Option<&str>;
/// Writes the experience orb `id`'s `value` (the points a pickup gives), `count`
/// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb
/// despawns at 6000); a `None` is left as it is. API 128.
/// False, and nothing written, for an orb not in the server level `dimension`, a
/// `value` or `count` of 0, and on a client of a server.
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool;
/// The experience orbs of the server level `dimension` whose box meets
/// the box `min`..`max`, in the level's order: what a mod reads before it
/// cancels an orb in `native_entity_join_level`, to give its points with
/// `native_give_experience`. Empty for a level not in place and on a
/// client of a server.
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb>;
/// The clock of the world this window shows, for client mods: its game
/// time and time of day as the server last sent them
/// (`ClientboundSetTimePacket`), advanced each tick between packets; none
/// on the title screen. API 92.
fn clock(&self) -> Option<NativeClock>;
/// Throws a falling block of `state` from `pos` (its bottom centre) at
/// `velocity` (blocks a tick) in the level in place, `dimension`: it
/// falls, lands and places itself as sand does, or drops as an item
/// where it can't. The block at `pos` is left as it is: take it away first
/// with `native_set_block` to make a block fall out of the world. False,
/// with nothing thrown, on a client's copy of the world, for another
/// dimension, an air or unknown state, a chunk that isn't loaded, a value
/// that isn't finite, or with 1024 falling blocks in the level already.
/// Each axis of `velocity` is cut to 3.9. API 81.
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool;
/// Property `key` of block state `state` (a machine's `facing`, from the
/// `state` `native_block_entity_submit` gets), if it has one: no lookup in
/// the world, so cheap each frame. API 91.
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str>;
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API 116.
fn raycast(&self, distance: f32) -> Option<NativeHit>;
/// The first block along the view within `RAYCAST_MAX`, and its distance. API 116.
fn view_hit(&self) -> Option<(f32, NativeHit)>;
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API 116.
fn claim_items(&mut self, mod_id: &str, ids: &[String]);
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API 116.
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent>;
/// Sets the stack of item entity `id` in the server level `dimension`, as
/// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`);
/// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then
@@ -0,0 +1,48 @@
//! Callbacks into the executable for native trait mods' client-library hooks.
use crate::items::ItemStack;
use serde_json::{Map, Value};
use std::sync::OnceLock;
/// Client-library hooks, installed before any menu, entity or recipe is used.
pub struct NativeClientHooks {
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
}
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
/// Install the process-wide callbacks once; a second installation keeps the first.
pub fn install(hooks: NativeClientHooks) -> Result<(), NativeClientHooks> {
HOOKS.set(hooks)
}
pub(crate) fn menu_slot_rules(
menu: &str,
slot: usize,
stack: &ItemStack,
may_place: &mut bool,
max_stack: &mut u32,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.menu_slot_rules)(menu, slot, stack, may_place, max_stack);
}
}
pub(crate) fn living_breathe(
entity: u32,
kind: &str,
can_breathe: &mut bool,
consume: &mut i32,
refill: &mut i32,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_breathe)(entity, kind, can_breathe, consume, refill);
}
}
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient)(kind, data, items);
}
}
@@ -156,7 +156,7 @@ impl Session {
let before = (self.streaming.generated_total, self.world.chunks.len());
self.update();
thread::sleep(Duration::from_millis(5));
let idle = self.streaming.pending() == 0
let idle = !self.streaming.busy()
&& before == (self.streaming.generated_total, self.world.chunks.len());
quiet = if idle { quiet + 1 } else { 0 };
}
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
// and nothing comes back (`pending` empties).
streaming.free(uploads);
thread::sleep(Duration::from_millis(5));
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
quiet = if idle { quiet + 1 } else { 0 };
}
};
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+1 -1
View File
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+6 -3
View File
@@ -753,7 +753,7 @@ mod tests {
*state
}
#[test]
fn runs_read_what_get_reads() {
fn runs_read_the_stored_values() {
let mut state = 0x9E37_79B9_7F4A_7C15;
// One value, a list palette, and the global palette.
for distinct in [1u64, 5, 3000] {
@@ -767,8 +767,11 @@ mod tests {
let len = len.min(4096 - start);
let mut run = vec![u32::MAX; len];
container.get_run(start, &mut run);
let expected: Vec<u32> = (start..start + len).map(|i| container.get(i)).collect();
assert_eq!(run, expected, "{distinct} values, {start}+{len}");
assert_eq!(
run,
values[start..start + len],
"{distinct} values, {start}+{len}"
);
}
}
}
@@ -0,0 +1,205 @@
//! Replays the play packets of a `horizon-bots --capture` file through the client's receive path on
//! one thread, and prints the CPU each stage takes and what each packet type costs to decode:
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 1 --behaviour explore --view-distance 32 \
//! --seconds 60 --capture join.cap
//! cargo run --release -p horizon-protocol --example net_replay -- join.cap
//!
//! The packets go through a connection three times, as they reach the client:
//! - `memory`: single player (an integrated server's pipe: no compression, no encryption);
//! - `tcp`: a server's default compression threshold (256), as offline servers send it;
//! - `tcp+aes`: the same encrypted, as online-mode servers send it.
//!
//! Each pass sends every packet into an in-memory pipe first, then times `ConnReader::recv` for all of
//! them (pipe read, decryption, framing, inflate). `decode` is `play::Clientbound::decode`, and
//! `sections` parses chunk packets' sections (overworld: 24 sections; `--biome-bits` from the server's
//! biome registry, 7 for vanilla's 65 biomes). `--repeat N` keeps each stage's fastest of N runs.
use horizon_protocol::{
Direction, State,
buf::write_varint,
connection::{Connection, Pipe},
ids,
packets::play::Clientbound,
};
use std::{collections::BTreeMap, error::Error, time::Instant};
struct Packet {
id: i32,
body: Vec<u8>,
}
fn load(path: &str) -> Result<Vec<Packet>, Box<dyn Error>> {
let data = std::fs::read(path)?;
let mut packets = Vec::new();
let mut at = 0;
while at + 12 <= data.len() {
let id = i32::from_le_bytes(data[at + 4..at + 8].try_into()?);
let len = u32::from_le_bytes(data[at + 8..at + 12].try_into()?) as usize;
let Some(payload) = data.get(at + 12..at + 12 + len) else {
break;
};
let mut body = Vec::with_capacity(len + 3);
write_varint(&mut body, id);
body.extend_from_slice(payload);
packets.push(Packet { id, body });
at += 12 + len;
}
Ok(packets)
}
/// Seconds to receive every packet through a connection with this threshold and encryption,
/// and the bytes the connection read.
fn receive(packets: &[Packet], threshold: i32, encrypt: bool) -> (f64, i64, Vec<(i32, Vec<u8>)>) {
let (a, b) = Pipe::pair();
let (mut server, mut client) = (Connection::local(b), Connection::local(a));
server.set_compression(threshold);
client.set_compression(threshold);
if encrypt {
let key = [0x5a; 16];
server.enable_encryption(&key);
client.enable_encryption(&key);
}
for p in packets {
server.send(&p.body).expect("the pipe is open");
}
let start = Instant::now();
let received: Vec<_> = (0..packets.len())
.map(|_| client.recv().expect("every packet sent arrives"))
.collect();
let seconds = start.elapsed().as_secs_f64();
let bytes = client
.reader
.counters
.bytes_in
.load(std::sync::atomic::Ordering::Relaxed);
(seconds, bytes, received)
}
#[derive(Default, Clone, Copy)]
struct Cost {
count: u64,
bytes: u64,
nanos: u64,
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
let path = args
.first()
.ok_or("usage: net_replay CAPTURE [--repeat N] [--biome-bits B]")?;
let flag = |name: &str, default: usize| {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let repeat = flag("--repeat", 3).max(1);
let biome_bits = flag("--biome-bits", 7) as u8;
let packets = load(path)?;
let body_bytes: usize = packets.iter().map(|p| p.body.len()).sum();
let chunk_id = ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT;
let chunks = packets.iter().filter(|p| p.id == chunk_id).count();
println!(
"{path}: {} packets, {:.1} MB of packet bodies, {chunks} chunk packets",
packets.len(),
body_bytes as f64 / 1e6
);
let mut received = Vec::new();
println!("\nreceive (ConnReader::recv for every packet, fastest of {repeat}):");
println!(
" {:<8} {:>10} {:>10} {:>12} {:>14}",
"pass", "wire MB", "ms", "ns/packet", "ns/wire byte"
);
for (name, threshold, encrypt) in [
("memory", -1, false),
("tcp", 256, false),
("tcp+aes", 256, true),
] {
let mut best = f64::MAX;
let mut wire = 0;
for _ in 0..repeat {
let (seconds, bytes, got) = receive(&packets, threshold, encrypt);
best = best.min(seconds);
wire = bytes;
received = got;
}
println!(
" {name:<8} {:>10.1} {:>10.1} {:>12.0} {:>14.2}",
wire as f64 / 1e6,
best * 1e3,
best * 1e9 / packets.len() as f64,
best * 1e9 / wire.max(1) as f64
);
}
// Decode, per packet type: the fastest of the runs for each type.
let mut costs: BTreeMap<i32, Cost> = BTreeMap::new();
let mut sections = Cost::default();
let mut errors = 0;
for run in 0..repeat {
let mut this: BTreeMap<i32, Cost> = BTreeMap::new();
let mut this_sections = Cost::default();
for (id, payload) in &received {
let start = Instant::now();
let packet = Clientbound::decode(*id, payload);
let nanos = start.elapsed().as_nanos() as u64;
let c = this.entry(*id).or_default();
c.count += 1;
c.bytes += payload.len() as u64;
c.nanos += nanos;
match packet {
Ok(Clientbound::LevelChunkWithLight(p)) => {
let start = Instant::now();
if p.chunk.sections(24, 16, biome_bits).is_err() && run == 0 {
errors += 1;
}
this_sections.count += 1;
this_sections.bytes += p.chunk.buffer.len() as u64;
this_sections.nanos += start.elapsed().as_nanos() as u64;
}
Err(_) if run == 0 => errors += 1,
_ => {}
}
}
for (id, c) in this {
let best = costs.entry(id).or_insert(c);
if c.nanos < best.nanos {
*best = c;
}
}
if run == 0 || this_sections.nanos < sections.nanos {
sections = this_sections;
}
}
let mut rows: Vec<_> = costs.into_iter().collect();
rows.sort_by_key(|(_, c)| std::cmp::Reverse(c.nanos));
let total: u64 = rows.iter().map(|(_, c)| c.nanos).sum();
println!(
"\ndecode (play::Clientbound::decode): {:.1} ms for {} packets, {errors} errors",
total as f64 / 1e6,
packets.len()
);
println!(
" {:<34} {:>8} {:>10} {:>10} {:>10}",
"packet", "count", "MB", "ms", "ns/packet"
);
for (id, c) in rows.iter().take(20) {
let name = ids::name(State::Play, Direction::Clientbound, *id).unwrap_or("?");
println!(
" {name:<34} {:>8} {:>10.2} {:>10.2} {:>10.0}",
c.count,
c.bytes as f64 / 1e6,
c.nanos as f64 / 1e6,
c.nanos as f64 / c.count as f64
);
}
println!(
"\nsections (LevelChunkPacketData.sections): {} chunks, {:.1} ms, {:.0} us a chunk",
sections.count,
sections.nanos as f64 / 1e6,
sections.nanos as f64 / 1e3 / sections.count.max(1) as f64
);
Ok(())
}
+700
View File
@@ -0,0 +1,700 @@
//! One pool of worker threads for the whole process, shared by every source of
//! background work: chunk generation, light and meshing (the client's and each
//! of the server thread's levels), chunk encoding, far terrain.
//!
//! Before it, each of those started its own threads, about one per core each:
//! a single player world ran four or five such pools at once, ~100 threads on a
//! 32-thread CPU, and they fought each other and the render and server threads
//! for cores. Here the threads are counted once, for the machine
//! ([`configure`]), and the work goes to them in the order it matters.
//!
//! A [`Source`] keeps its own queue (it can rank its jobs again when the player
//! moves, or drop the ones that left the view); a worker that is free asks every
//! source for the [`Rank`] of its next job and runs the most urgent one: the
//! [`Class`] first, then the source's key (a chunk's distance from the player).
//! Every free worker takes the next job as soon as it is free, so no core waits
//! while work is queued, whichever source queued it. Jobs are coarse (a chunk, a
//! section, a tile: a millisecond or more), so one lock for the choice costs
//! nothing next to them.
//!
//! A worker asks the sources for their next rank under the pool's read lock
//! of its sources and nothing else, so a source may call [`Pool::notify`]
//! anywhere, its queue's lock held or not; a notify never waits for a worker.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, RwLock};
use std::time::Duration;
/// How urgent a job is, before its source's key: every queued job of a lower
/// class runs before any of a higher one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Class {
/// Work something waits for now: saves, a tick's chunk encoding.
Urgent,
/// The chunks around the player: generation, light, meshing.
Near,
/// Far terrain: runs on what the near work leaves, and never on every
/// worker at once, so near work that arrives finds a worker free.
Far,
}
/// A job's place in the pool's order: lower runs first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rank {
pub class: Class,
/// The source's own order within the class (a column's distance).
pub key: i64,
}
impl Rank {
pub const fn new(class: Class, key: i64) -> Self {
Self { class, key }
}
}
/// Work queued for the pool.
pub trait Source: Send + Sync {
/// The rank of the job [`Source::run`] would take next, or None while
/// nothing is queued. Called under the pool's lock: cheap, no blocking.
fn next(&self) -> Option<Rank>;
/// Take the next job and run it on worker `worker` (0..[`Pool::threads`]).
/// False if there was none after all.
fn run(&self, worker: usize) -> bool;
/// Worker `worker` has waited [`IDLE_RELEASE`] with no work: free what it
/// keeps between jobs.
fn idle(&self, _worker: usize) {}
/// Every worker is idle: free what the workers share.
fn all_idle(&self) {}
}
/// How long a worker waits with no job before it frees its buffers.
pub const IDLE_RELEASE: Duration = Duration::from_secs(5);
/// Per-worker state for a source: worker `i` only ever touches slot `i`, so
/// the locks are never contended. The source owns it, so it goes with the source.
pub struct PerWorker<T> {
slots: Box<[Mutex<Option<T>>]>,
}
impl<T> PerWorker<T> {
pub fn new(workers: usize) -> Self {
Self {
slots: (0..workers.max(1)).map(|_| Mutex::new(None)).collect(),
}
}
/// `f` with worker `worker`'s state, made by `init` the first time.
pub fn with<R>(
&self,
worker: usize,
init: impl FnOnce() -> T,
f: impl FnOnce(&mut T) -> R,
) -> R {
let mut slot = lock(&self.slots[worker % self.slots.len()]);
f(slot.get_or_insert_with(init))
}
/// `f` with worker `worker`'s state if it has one.
pub fn with_existing<R>(&self, worker: usize, f: impl FnOnce(&mut T) -> R) -> Option<R> {
lock(&self.slots[worker % self.slots.len()]).as_mut().map(f)
}
/// Drop worker `worker`'s state.
pub fn clear(&self, worker: usize) -> Option<T> {
lock(&self.slots[worker % self.slots.len()]).take()
}
}
struct Entry {
id: u64,
source: Arc<dyn Source>,
/// Workers running one of its jobs.
running: AtomicUsize,
/// Taken out of the pool: the worker that ends its last job tells
/// [`Registration`]'s drop, which waits for it.
retired: AtomicBool,
}
struct Inner {
/// Read by every choice, written only to add or take out a source.
sources: RwLock<Vec<Arc<Entry>>>,
next_id: AtomicU64,
/// Bumped by every [`Pool::notify`]: a worker that found nothing sleeps
/// until it changes, so a job queued while it looked is not missed. A
/// notify is two atomic operations while every worker is busy, and takes
/// `sleep` (held by no source code) only to wake one: the thread queuing
/// work (a frame, a tick) never waits behind a worker's choice.
epoch: AtomicU64,
sleepers: AtomicUsize,
sleep: Mutex<()>,
wake: Condvar,
/// Signalled when a retired source's job ends.
finished: Condvar,
/// Workers running a [`Class::Far`] job.
far: AtomicUsize,
/// Workers that freed their buffers and took no job since.
released: AtomicUsize,
threads: AtomicUsize,
idle_release: Duration,
}
/// The pool: [`pool`] for the process's own, [`Pool::new`] for a test's.
pub struct Pool {
inner: Arc<Inner>,
threads: usize,
}
/// A source's place in the pool; dropping it takes the source out and waits
/// for the jobs of it that are running (so never drop it in one of its own jobs).
pub struct Registration {
inner: Arc<Inner>,
id: u64,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
// A panic in a job is caught; the pool's own state is never left half
// changed, so a poisoned lock is still consistent.
m.lock().unwrap_or_else(|e| e.into_inner())
}
static CONFIGURED: AtomicUsize = AtomicUsize::new(0);
static POOL: OnceLock<Pool> = OnceLock::new();
/// The number of workers the machine gets by default: one thread per core
/// but one, which the render and server threads share with them (they run
/// ahead of the workers: [`crate::favour_current`], [`crate::behind_server`]).
pub fn default_threads() -> usize {
std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1)
}
/// Size the process's pool before its first use (later calls change
/// nothing): `threads` workers, or `HORIZON_JOB_THREADS` when it is set.
pub fn configure(threads: usize) {
CONFIGURED.store(threads.max(1), Ordering::Relaxed);
}
/// The process's pool, started on first use.
pub fn pool() -> &'static Pool {
POOL.get_or_init(|| {
let threads = std::env::var("HORIZON_JOB_THREADS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(|| match CONFIGURED.load(Ordering::Relaxed) {
0 => default_threads(),
n => n,
});
// The name the crash reports know a worker by (vanilla's `Util` workers).
Pool::new(threads, "horizon-worker")
})
}
impl Pool {
/// A pool of `threads` workers named `name-<i>`. Workers that the system
/// refuses to start leave it smaller ([`Pool::threads`] counts those that started).
pub fn new(threads: usize, name: &str) -> Self {
Self::with_idle_release(threads, name, IDLE_RELEASE)
}
/// [`Pool::new`] whose workers free their buffers after `idle_release`.
pub fn with_idle_release(threads: usize, name: &str, idle_release: Duration) -> Self {
let inner = Arc::new(Inner {
sources: RwLock::new(Vec::new()),
next_id: AtomicU64::new(0),
epoch: AtomicU64::new(0),
sleepers: AtomicUsize::new(0),
sleep: Mutex::new(()),
wake: Condvar::new(),
finished: Condvar::new(),
far: AtomicUsize::new(0),
released: AtomicUsize::new(0),
threads: AtomicUsize::new(threads.max(1)),
idle_release,
});
let mut started = 0;
for i in 0..threads.max(1) {
let worker = inner.clone();
let spawned = std::thread::Builder::new()
.name(format!("{name}-{i}"))
.spawn(move || {
// Ticks and frames come first.
crate::behind_server();
work(&worker, i);
});
if spawned.is_ok() {
started += 1;
}
}
inner.threads.store(started.max(1), Ordering::Relaxed);
Self {
inner,
threads: started.max(1),
}
}
/// The workers: the size of a source's [`PerWorker`] and of the work it
/// keeps queued.
pub fn threads(&self) -> usize {
self.threads
}
/// Add a source; its jobs run until the registration is dropped.
pub fn register(&self, source: Arc<dyn Source>) -> Registration {
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
self.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner())
.push(Arc::new(Entry {
id,
source,
running: AtomicUsize::new(0),
retired: AtomicBool::new(false),
}));
self.inner.notify(true);
Registration {
inner: self.inner.clone(),
id,
}
}
/// Wake a worker for a job just queued (after the queue's lock is released).
pub fn notify(&self) {
self.inner.notify(false);
}
/// Wake every worker: many jobs were queued at once.
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Inner {
fn notify(&self, all: bool) {
self.epoch.fetch_add(1, Ordering::SeqCst);
if self.sleepers.load(Ordering::SeqCst) > 0 {
// A worker between counting itself asleep and waiting holds
// `sleep`: taking it here means it is waiting when told.
drop(lock(&self.sleep));
if all {
self.wake.notify_all();
} else {
self.wake.notify_one();
}
}
}
}
impl Registration {
/// Wake a worker for a job just queued: [`Pool::notify`].
pub fn notify(&self) {
self.inner.notify(false);
}
pub fn notify_all(&self) {
self.inner.notify(true);
}
}
impl Drop for Registration {
fn drop(&mut self) {
let entry = {
let mut sources = self
.inner
.sources
.write()
.unwrap_or_else(|e| e.into_inner());
let Some(at) = sources.iter().position(|e| e.id == self.id) else {
return;
};
sources.swap_remove(at)
};
// No worker takes a job of it now (a worker counts itself running
// while it holds the sources' read lock); wait for those that did.
entry.retired.store(true, Ordering::SeqCst);
let mut sleep = lock(&self.inner.sleep);
while entry.running.load(Ordering::SeqCst) > 0 {
sleep = self
.inner
.finished
.wait(sleep)
.unwrap_or_else(|e| e.into_inner());
}
drop(sleep);
// The source's last reference may go here, outside every lock.
drop(entry);
}
}
/// The most urgent queued job's source, counted as running, if any may run now.
fn choose(inner: &Inner) -> Option<(Arc<Entry>, bool)> {
let threads = inner.threads.load(Ordering::Relaxed);
// Far work keeps one worker free for near work (when there are two or more).
let far_cap = threads.saturating_sub(1).max(1);
let sources = inner.sources.read().unwrap_or_else(|e| e.into_inner());
let mut skip_far = false;
loop {
let far_open = !skip_far && inner.far.load(Ordering::SeqCst) < far_cap;
let mut best: Option<(&Arc<Entry>, Rank)> = None;
for entry in sources.iter() {
let Some(rank) = entry.source.next() else {
continue;
};
if rank.class == Class::Far && !far_open {
continue;
}
if best.is_none_or(|(_, b)| rank < b) {
best = Some((entry, rank));
}
}
let (entry, rank) = best?;
let far = rank.class == Class::Far;
if far && !take_far_place(inner, far_cap) {
// Another worker took the last far place meanwhile.
skip_far = true;
continue;
}
entry.running.fetch_add(1, Ordering::SeqCst);
return Some((entry.clone(), far));
}
}
/// One more far job running, if fewer than `cap` are.
fn take_far_place(inner: &Inner, cap: usize) -> bool {
let mut n = inner.far.load(Ordering::SeqCst);
while n < cap {
match inner
.far
.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => return true,
Err(now) => n = now,
}
}
false
}
fn work(inner: &Inner, worker: usize) {
let mut released = false;
loop {
let epoch = inner.epoch.load(Ordering::SeqCst);
if let Some((entry, far)) = choose(inner) {
if released {
inner.released.fetch_sub(1, Ordering::Relaxed);
released = false;
}
// A panicking job loses its own work, not the worker: the panic
// hook has already reported it.
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.run(worker)));
if far {
inner.far.fetch_sub(1, Ordering::SeqCst);
}
entry.running.fetch_sub(1, Ordering::SeqCst);
if entry.retired.load(Ordering::SeqCst) {
drop(lock(&inner.sleep));
inner.finished.notify_all();
}
continue;
}
let sleep = lock(&inner.sleep);
inner.sleepers.fetch_add(1, Ordering::SeqCst);
let still = |_: &mut ()| inner.epoch.load(Ordering::SeqCst) == epoch;
let timed_out = if released {
drop(
inner
.wake
.wait_while(sleep, still)
.unwrap_or_else(|e| e.into_inner()),
);
false
} else {
let (sleep, wait) = inner
.wake
.wait_timeout_while(sleep, inner.idle_release, still)
.unwrap_or_else(|e| e.into_inner());
drop(sleep);
wait.timed_out()
};
inner.sleepers.fetch_sub(1, Ordering::SeqCst);
if !timed_out {
continue;
}
// Nothing to do for a while (a server nobody is on, a player standing
// still): free this worker's buffers until the next job needs them.
released = true;
let last = inner.released.fetch_add(1, Ordering::Relaxed) + 1
== inner.threads.load(Ordering::Relaxed);
let sources: Vec<_> = inner
.sources
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|e| e.source.clone())
.collect();
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.idle(worker)));
}
if last {
// The allocator keeps freed pages until memory runs short: once
// every worker has freed its own, hand them back.
for source in &sources {
let _ = catch_unwind(AssertUnwindSafe(|| source.all_idle()));
}
crate::release_free_memory();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::mpsc;
use std::time::Instant;
/// Jobs with fixed ranks; each records its name when it runs.
struct Fixed {
jobs: Mutex<VecDeque<(Rank, &'static str)>>,
ran: mpsc::Sender<&'static str>,
hold: Option<Arc<(Mutex<bool>, Condvar)>>,
}
impl Source for Fixed {
fn next(&self) -> Option<Rank> {
lock(&self.jobs).iter().map(|(r, _)| *r).min()
}
fn run(&self, _worker: usize) -> bool {
let job = {
let mut jobs = lock(&self.jobs);
let Some(at) = jobs
.iter()
.enumerate()
.min_by_key(|(_, (r, _))| *r)
.map(|(i, _)| i)
else {
return false;
};
jobs.remove(at).unwrap()
};
if let Some(hold) = &self.hold {
let (open, cv) = &**hold;
let mut open = lock(open);
while !*open {
open = cv.wait(open).unwrap();
}
}
let _ = self.ran.send(job.1);
true
}
}
fn fixed(jobs: &[(Rank, &'static str)], ran: mpsc::Sender<&'static str>) -> Arc<Fixed> {
Arc::new(Fixed {
jobs: Mutex::new(jobs.iter().copied().collect()),
ran,
hold: None,
})
}
fn recv_n(rx: &mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
(0..n)
.map(|_| rx.recv_timeout(Duration::from_secs(10)).unwrap())
.collect()
}
#[test]
fn one_worker_runs_every_source_in_rank_order() {
let pool = Pool::new(1, "test-job");
let (tx, rx) = mpsc::channel();
// Held until both sources are in, so the order is the pool's choice.
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let first = Arc::new(Fixed {
jobs: Mutex::new([(Rank::new(Class::Urgent, i64::MIN), "gate")].into()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _g = pool.register(first);
let a = fixed(
&[
(Rank::new(Class::Near, 5), "near5"),
(Rank::new(Class::Far, 0), "far0"),
],
tx.clone(),
);
let b = fixed(
&[
(Rank::new(Class::Near, 1), "near1"),
(Rank::new(Class::Urgent, 9), "urgent9"),
],
tx,
);
let _a = pool.register(a);
let _b = pool.register(b);
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(
recv_n(&rx, 5),
["gate", "urgent9", "near1", "near5", "far0"]
);
}
#[test]
fn far_work_leaves_a_worker_for_near_work() {
let pool = Pool::new(3, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let far = Arc::new(Fixed {
jobs: Mutex::new((0..6).map(|i| (Rank::new(Class::Far, i), "far")).collect()),
ran: tx.clone(),
hold: Some(gate.clone()),
});
let _far = pool.register(far);
// Two far jobs hold two workers; the third stays free for this one.
std::thread::sleep(Duration::from_millis(100));
let near = fixed(&[(Rank::new(Class::Near, 0), "near")], tx);
let near_reg = pool.register(near);
near_reg.notify();
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), "near");
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
assert_eq!(recv_n(&rx, 6), ["far"; 6]);
}
/// A source that counts its jobs, queued from outside while the workers run.
struct Counter {
left: Mutex<u64>,
done: AtomicUsize,
per_worker: PerWorker<u64>,
idle: AtomicBool,
all_idle: AtomicBool,
}
impl Source for Counter {
fn next(&self) -> Option<Rank> {
(*lock(&self.left) > 0).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, worker: usize) -> bool {
{
let mut left = lock(&self.left);
if *left == 0 {
return false;
}
*left -= 1;
}
self.per_worker.with(worker, || 0, |n| *n += 1);
self.done.fetch_add(1, Ordering::Relaxed);
true
}
fn idle(&self, worker: usize) {
self.per_worker.clear(worker);
self.idle.store(true, Ordering::Relaxed);
}
fn all_idle(&self) {
self.all_idle.store(true, Ordering::Relaxed);
}
}
#[test]
fn no_job_is_missed_when_queued_while_workers_look() {
let pool = Pool::new(4, "test-job");
let counter = Arc::new(Counter {
left: Mutex::new(0),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
for _ in 0..20_000 {
*lock(&counter.left) += 1;
reg.notify();
}
let deadline = Instant::now() + Duration::from_secs(20);
while counter.done.load(Ordering::Relaxed) < 20_000 {
assert!(Instant::now() < deadline, "jobs left behind");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn idle_workers_free_their_state() {
let release = Duration::from_millis(300);
let pool = Pool::with_idle_release(2, "test-job", release);
let counter = Arc::new(Counter {
left: Mutex::new(10),
done: AtomicUsize::new(0),
per_worker: PerWorker::new(pool.threads()),
idle: AtomicBool::new(false),
all_idle: AtomicBool::new(false),
});
let reg = pool.register(counter.clone());
reg.notify_all();
let deadline = Instant::now() + release * 20;
while !counter.all_idle.load(Ordering::Relaxed) {
assert!(Instant::now() < deadline, "workers never released");
std::thread::sleep(Duration::from_millis(20));
}
assert!(counter.idle.load(Ordering::Relaxed));
assert_eq!(counter.done.load(Ordering::Relaxed), 10);
assert!(counter.per_worker.slots.iter().all(|s| lock(s).is_none()));
}
#[test]
fn dropping_a_registration_waits_for_its_running_job() {
let pool = Pool::new(2, "test-job");
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let (tx, rx) = mpsc::channel();
let held = Arc::new(Fixed {
jobs: Mutex::new(
[
(Rank::new(Class::Near, 0), "held"),
(Rank::new(Class::Near, 1), "second"),
]
.into(),
),
ran: tx,
hold: Some(gate.clone()),
});
let reg = pool.register(held.clone());
std::thread::sleep(Duration::from_millis(100));
let opener = {
let gate = gate.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
})
};
let began = Instant::now();
drop(reg);
// Both jobs may have started (two workers); none runs after the drop.
assert!(began.elapsed() >= Duration::from_millis(150));
opener.join().unwrap();
let ran: Vec<_> = rx.try_iter().collect();
assert!(ran.contains(&"held"));
std::thread::sleep(Duration::from_millis(100));
assert!(rx.try_iter().next().is_none());
// The pool's own references are gone.
assert_eq!(Arc::strong_count(&held), 1);
}
#[test]
fn a_panicking_job_keeps_its_worker() {
struct Boom(AtomicUsize);
impl Source for Boom {
fn next(&self) -> Option<Rank> {
(self.0.load(Ordering::Relaxed) < 2).then_some(Rank::new(Class::Near, 0))
}
fn run(&self, _worker: usize) -> bool {
if self.0.fetch_add(1, Ordering::Relaxed) == 0 {
panic!("first job fails");
}
true
}
}
let pool = Pool::new(1, "test-job");
let boom = Arc::new(Boom(AtomicUsize::new(0)));
let reg = pool.register(boom.clone());
reg.notify();
let deadline = Instant::now() + Duration::from_secs(10);
while boom.0.load(Ordering::Relaxed) < 2 {
assert!(Instant::now() < deadline, "the worker died with the panic");
std::thread::sleep(Duration::from_millis(5));
}
}
}
+1
View File
@@ -4,6 +4,7 @@
/// The game window's boot screen, before wgpu's first frame paints over it.
#[cfg(target_os = "windows")]
pub mod boot_paint;
pub mod jobs;
pub mod memory_limit;
use std::sync::atomic::{AtomicBool, Ordering};
+1
View File
@@ -8,6 +8,7 @@ description = "Horizon vanilla world data, NBT, regions and chunk storage"
[dependencies]
libm = "=0.2.16"
log = "0.4"
serde_json = { version = "1.0.151", features = ["raw_value"] }
serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more