Compare commits

...
Author SHA1 Message Date
Ludovic Marie 4fbb0cde70 Merge pull request #1837 from ludovic111/feat/server-mod-test-scripts
🤖 Agent: The test scripts for server mods. CI is green on d64bd7c52, and the PR is now based on main after #1833 merged. I ran pr_ready --quick on this head merged with main (b8027d4b1): fmt, clippy, the horizon-mods and welcome tests (48) and the launcher tests (132) all pass. The only affected harness is lan_integration, which runs in CI. Queued.
2026-10-26 12:43:44 +01:00
Ludovic Marie 6ff335eacf Merge pull request #2112 from ludovic111/feat/native-material-plumbing
🤖 Agent: verified: pr_ready --quick green, 30/30 affected harnesses pass locally, CI verify green. Internal: one WGSL prefixer for the shader-pack stages (no visible change).
2026-10-26 12:32:07 +01:00
Ludovic Marie b8027d4b19 Merge pull request #1765 from ludovic111/feat/lithostitched-processors
Lithostitched's processors as the game's own types; replace the old Lithostitched conversion
2026-10-26 12:24:09 +01:00
Ludovic Marie 4d98e545eb Merge pull request #2098 from ludovic111/feat/launcher-mod-api-level
🤖 Agent: The launcher half of the mod API levels. CI is green on c8464c6bf, and the PR is now based on main after #2080 merged. Launcher tests pass, the new browser test included. The level mark is read from a real fast build of the game. Queued.
2026-10-26 12:22:55 +01:00
Ludovic Marie e34cb7c021 Merge pull request #2080 from ludovic111/feat/mod-api-level
🤖 Agent: Mod API levels. CI is green on b12c89650. Locally: horizon-mods lib 31, api_reference 4 and sandbox 26 pass. A dummy component that calls a function from a newer level is refused with "update Horizon". Level 1 is render.set-minimap and the players.* functions; level 0 matches 0.2.67 to 0.2.69. Queued.
2026-10-26 12:06:13 +01:00
Ludovic Marie b66861c721 Merge pull request #2106 from ludovic111/ci/nightly-schedule-guard
🤖 Agent: nightly schedule guard by Paris offset, lan_integration timeout; YAML checked.
2026-10-26 11:59:55 +01:00
Ludovic Marie 27f63ce50c Merge pull request #1975 from kacpherek/fix/1523-tp-during-arrival
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:55:20 +01:00
Ludovic Marie 8682fb845d Merge pull request #1971 from kacpherek/fix/1477-firework-player-damage
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:52:34 +01:00
Ludovic MarieandClaude Opus 5.5 189b2c6b3c nightly: pick the 03:07 Paris schedule by offset, not by the clock at start; room for lan_integration
GitHub started both 10-26 schedules over an hour late, so the hour check skipped both and no audit ran.
lan_integration takes ~2,400 s under load, past the 1,800 s per-harness limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:52:09 +01:00
Ludovic Marie c048292f3f Merge pull request #1909 from rdbtCVS/t3/server-thread-integration-clean
🤖 Agent: gate verified on e3e08911b (main merged). fmt, clippy -D warnings and horizon-client tests pass. lan_e2e with the server-thread host: 131/133, and cursor/falling pass on rerun in both modes. 14 in-frame checks pass. agent_control_e2e: only manage fails, and it fails the same way on main. 82 of the 83 affected harnesses pass. lan_integration flakes under load and is now named-only (nightly). Queued.
2026-10-26 11:47:59 +01:00
Ludovic Marie c8464c6bff Merge branch 'feat/mod-api-level' into feat/launcher-mod-api-level 2026-10-26 11:46:43 +01:00
Ludovic MarieandClaude Opus 5.5 b12c89650e Mod API levels: server mods' players functions are level 1; the registry's lockfile
Main gained players.online, tell and broadcast (#1833) after 0.2.69, so
they take level 1 with render.set-minimap. horizon-mod-registry's own
Cargo.lock picks up wasmparser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:46:27 +01:00
Ludovic Marie 0a9a7c0d06 Merge remote-tracking branch 'origin/main' into feat/mod-api-level 2026-10-26 11:45:15 +01:00
Ludovic Marie d104a350cd Merge pull request #1980 from kacpherek/fix/place-sound-refused
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:45:09 +01:00
Ludovic Marie 6cd4238930 Merge pull request #1979 from kacpherek/fix/1899-swim-ceiling
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:42:32 +01:00
Ludovic Marie 3c1ab736d5 Merge pull request #1978 from kacpherek/fix/1881-death-camera-tilt
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:39:58 +01:00
Ludovic Marie a31a435525 Merge pull request #1976 from kacpherek/fix/1526-gateway-cooldown
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:37:21 +01:00
Ludovic Marie 7650451b4f Merge pull request #1974 from kacpherek/fix/1500-vehicle-storage
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:34:26 +01:00
Ludovic MarieandClaude Opus 5.5 3c22ff0849 Launcher: don't install a mod version the game can't run
The game program carries its mod API level in a mark
(horizon-mod-api-level:NNNN;), which the launcher reads once per
installed version; a program from before the mark (0.2.68 and older)
serves level 0. The Horizon browser marks versions above the level of the
game the player runs (the selected version, else the newest installed)
"needs a newer Horizon than the game you play", and installs the newest
version that game can run, or says to update it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:33:30 +01:00
Ludovic Marie c0171fa6bc Merge pull request #1973 from kacpherek/fix/1497-machine-menu-changes
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:31:24 +01:00
Ludovic Marie 707348fcd5 Merge pull request #1972 from kacpherek/fix/1493-shulker-guest-homing
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:28:33 +01:00
Ludovic Marie 8a458496c7 Merge pull request #1984 from rdbtCVS/fix/1956-item-fluid-current
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:23:24 +01:00
Ludovic Marie 3144276935 Merge pull request #1970 from kacpherek/fix/1328-respawn-after-trip-death
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:22:15 +01:00
Ludovic Marie 6c995bfa15 Merge remote-tracking branch 'origin/main' into feat/mod-api-level
# Conflicts:
#	crates/horizon-mods/Cargo.toml
#	crates/horizon-mods/src/index/tests.rs
#	launcher/Cargo.lock
2026-10-26 11:09:19 +01:00
Ludovic Marie 88b7bfafc0 Merge pull request #1833 from ludovic111/feat/server-mods-0.3
🤖 Agent: merged main (12 files conflicted with ui, render and input). The runtime now keeps the server/client Hooks split beside the ui-events export, and the Snapshot carries both the GUI fields and the server's players. Locally: the horizon-mods sandbox and unit tests, cargo-horizon-mod tests, and clippy on the client with mods all pass. CI verify and parity pass at b90ab85f4. Bottom of the server-mods stack (#1837, #1894, #1895, #1901 follow).
2026-10-26 11:07:35 +01:00
Ludovic Marie 2a0f4e39a8 Merge pull request #2036 from ludovic111/feat/launcher-shader-packs
🤖 Agent: I looked at the launcher's Shader packs tab in dark and light (Mac mini, mktemp homes, a local registry with the sample pack): Soft Glow is listed from Horizon with Works, Page and Install, and the look matches the other tabs. pr_ready --quick is green, with the launcher's 130 tests.
2026-10-26 10:57:13 +01:00
Ludovic MarieandClaude Opus 5.5 acbcaad743 Horizon shader packs: one WGSL prefixer for copies of the renderer's shaders
The sky stage runs a copy of terrain.wgsl's sky beside the pack's code
with every name prefixed `horizon_sky_`. The prefixer moves to
`gfx/wgsl_prefix.rs` and now also renames module variables (bindings)
and overrides, so the material stage can copy the terrain section the
same way. The sky's shader is unchanged apart from where `globals` is
declared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:56:39 +01:00
Ludovic MarieandClaude Opus 5.5 e3e08911ba parity: run lan_integration only when named and in the nightly audit
The harness runs the whole lan_e2e suite: ~40 minutes on a loaded runner,
and single checks flake under load (barter, riding, shulker_bullets in one
run; cursor, falling in another, all passing on rerun). Selected by
--changed it would run on most gameplay pull requests and on parity-main.
tools/parity_hosts.json gains a named_only table that every automatic
selection (--changed, --failed, the default, --flake-check) leaves out;
naming the harness or --all (the nightly audit) still runs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:48:58 +01:00
Ludovic Marie 87ff54e702 Merge remote-tracking branch 'origin/main' into feat/launcher-shader-packs
# Conflicts:
#	docs/shaders.md
2026-10-26 10:40:12 +01:00
Ludovic MarieandClaude Opus 5.5 8cc8c6180d Mod API levels: refuse a mod made for a newer Horizon, list each version's level
Every function the game offers mods has the API level it arrived at
(crates/horizon-mods/src/api_level.rs): level 0 is the contract of
Horizon 0.2.67 and 0.2.68, level 1 adds render.set-minimap. A mod needs
the level of the newest function it imports, read from its component.

- The game refuses a mod that calls a function it doesn't have with
  "made for a newer Horizon (it uses ...): update Horizon to run it"
  instead of wasmtime's linking error.
- The registry index lists each mod version's level (api_level, read
  from the file by CI); check_file compares it when the entry has one,
  and the moved extras' installer takes the newest version this game
  can run.
- cargo horizon-mod check prints the level.
- The api_reference test fails until a new WIT function has a level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:39:47 +01:00
Ludovic Marie 852e7cd524 Merge pull request #1855 from ludovic111/feat/native-shaders-registry
🤖 Agent: verified: pr_ready --quick green (launcher and horizon-mods tests), CI verify and check green, merges cleanly with main. The registry takes Horizon shader packs, checked by the game's own shader check.
2026-10-26 10:39:24 +01:00
Ludovic Marie 28ef79ed66 Merge pull request #1863 from ludovic111/feat/native-shaders-sky
🤖 Agent: verified: pr_ready --quick green, 80/80 affected harnesses pass locally, CI verify green, A/B shows no frame cost without a pack. A Horizon shader pack's sky stage now draws in the game.
2026-10-26 10:36:58 +01:00
Ludovic Marie f52d053817 Merge pull request #2063 from ludovic111/feat/moved-minimap
🤖 Agent: verify passed. Checked against the real registry now that Minimap 1.0.0 is published: the migration e2e passes 34/34 on the server thread and 34/34 in-frame. The minimap case starts at bottom left, 128, round, 0.5 blocks per pixel, turning: the mod is installed and on, its settings carried, the map shows, and the Extras page has no minimap rows. pr_ready --quick passes (clippy, 1295 horizon-client tests).
2026-10-26 10:30:49 +01:00
Ludovic Marie d64bd7c528 Merge branch 'feat/server-mods-0.3' into feat/server-mod-test-scripts
# Conflicts:
#	crates/horizon-mods/src/testing.rs
2026-10-26 09:56:41 +01:00
Ludovic Marie 45bc45b396 Merge pull request #2057 from ludovic111/perf/remote-chunks-off-render
🤖 Agent: pr_ready passed (78 harnesses), lan_e2e in-frame and server-thread passed; frame p99 4.96→3.95 ms during a RD 32 flight. Queued for merge.
2026-10-26 09:56:18 +01:00
Ludovic Marie 6f0c0c13b6 Merge pull request #2051 from ludovic111/feat/native-shader-frame
🤖 Agent: verified: pr_ready --quick green, 38/38 affected harnesses pass locally, CI verify green, A/B shows no frame cost. Internal: a Horizon shader pack's frame uniform is shared by its stages (no visible change).
2026-10-26 09:54:11 +01:00
Ludovic Marie b90ab85f44 Merge remote-tracking branch 'origin/main' into feat/server-mods-0.3
# Conflicts:
#	Cargo.toml
#	crates/cargo-horizon-mod/src/check.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-mod-sdk/src/lib.rs
#	crates/horizon-mod-sdk/wit/horizon.wit
#	crates/horizon-mods/src/manifest.rs
#	crates/horizon-mods/src/runtime.rs
#	crates/horizon-mods/src/testing.rs
#	crates/horizon-mods/tests/sandbox.rs
#	docs/api/mods-reference.md
#	docs/api/mods.md
#	docs/mods.md
2026-10-26 09:51:00 +01:00
Ludovic Marie b110608a93 Merge remote-tracking branch 'origin/main' into feat/moved-minimap 2026-10-26 09:43:44 +01:00
Ludovic MarieandClaude Opus 5.5 382380fc8b Minimap moved to the mod browser: remove the built-in minimap
The official Minimap mod (mods/official/minimap) shows the extra's map
through render.set-minimap. The extra's switch and its five Extras rows
go; the game still draws a mod's map from the same tiles. A player who
had the minimap on gets the mod installed and turned on at the first
start, with the corner, size, shape, zoom and turning carried into its
storage, and one chat line. horizon-extras.txt keeps the old keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:38:52 +01:00
Ludovic Marie 9e447a7e92 Merge pull request #2058 from ludovic111/feat/official-minimap-mod
🤖 Agent: The official Minimap mod (mods/official/minimap): the minimap extra as a mod with its five options. Pixel-identical to the extra in the map region (0.000 to 0.002) with default and other settings, at the same frame cost. Verified: pr_ready (2179 workspace tests, clippy), the settings page in game, and the published mods loading beside it in both modes.
2026-10-26 09:34:51 +01:00
Ludovic MarieandClaude Opus 5.5 4498df8be5 Merge feat/official-minimap-mod into feat/moved-minimap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:24:17 +01:00
Ludovic Marie 57c5f81299 Merge pull request #2028 from ludovic111/feat/shader-packs-page
🤖 Agent: verified: pr_ready --quick green, 38/38 affected harnesses pass locally, CI verify green, paired A/B shows no frame cost. Options > Horizon Settings > Shader Packs lists and picks packs of either kind.
2026-10-26 09:22:21 +01:00
Ludovic Marie ce46917121 Merge pull request #2056 from ludovic111/feat/mod-minimap-api
🤖 Agent: Mod API render.set-minimap: a mod picks the corner, size, scale, shape and turning, and the game draws the map from the minimap extra's tiles. It is a new function, so published mods keep loading. Verified: pr_ready (1353 tests, clippy, launcher), the sandbox test, and in game beside the published mods in both modes.
2026-10-26 09:12:15 +01:00
Ludovic Marie 09d61ec866 Merge pull request #2030 from ludovic111/feat/moved-zoom
🤖 Agent: Zoom moved to the mod browser. The built-in zoom is removed, and a player who had it on gets the published Zoom mod with their settings and key. Verified against the real registry, in both modes (54/54), plus clippy and the client tests.
2026-10-26 09:09:19 +01:00
Ludovic MarieandClaude Opus 5.5 427b48aa42 Server chunk packets decode on their own thread, not in the frame
A server's chunk columns (single player on its server thread, LAN,
servers) were decoded on the render thread: sections, palettes, biomes
and light, about 0.1 ms each on an M4 Pro. Once the backlog was overdue,
a frame decoded every waiting column at once. Now each chunk packet goes
to a decoder thread as soon as it arrives, and the frame that reaches it
in order only inserts the column. A column still decoding holds back what
follows it, overdue or not, so a frame never waits for the decoder. A
dimension or registry change since a column was decoded makes the frame
decode it again with the new format.

Forgetting a column removed its pending sections with a pass over all of
them. A moved or shrunk storage forgets thousands of columns in one frame,
so it now removes them by key and drops their block entities in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:05:47 +01:00
Ludovic Marie 2b54c8a4ab Merge pull request #1812 from ludovic111/fix/trapped-chest-signal-barrel-open
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:57:39 +01:00
Ludovic Marie ff26716cd1 Merge pull request #1809 from ludovic111/fix/portal-effect-on-server-clients
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:55:30 +01:00
Ludovic MarieandClaude Opus 5.5 71126d3e13 Merge feat/mod-minimap-api (with main) into feat/official-minimap-mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:55:08 +01:00
Ludovic MarieandClaude Opus 5.5 641ff51089 Merge main into feat/mod-minimap-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:55:02 +01:00
Ludovic Marie 45bd296f14 Merge pull request #1896 from ludovic111/fix/creative-drop-on-server
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:53:22 +01:00
Ludovic MarieandClaude Opus 5.5 cad817c82d Horizon shader packs: the sky stage runs in the game
A pack's `sky` function gets vanilla's colour along each view ray and draws
where the vanilla sky dome draws, under the sun, moon, stars and clouds. The
pipeline's shader is the pack's checked stage (`CheckedStage::source`, new)
followed by a copy of terrain.wgsl's sky with every name prefixed
`horizon_sky_`, so none can meet a pack's; terrain.wgsl's `fs_sky` now calls
`sky_along_ray`, the same code as before.

A running pack is one `NativePack`: the frame every stage reads (written once
a frame), its sky and post stages and its pass timer. In a frame F3 times, the
sky draws in a pass of its own so the overlay can show its time; otherwise it
stays in the world's pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:42:57 +01:00
Ludovic MarieandClaude Opus 5.5 a68c2f587d Horizon shader packs: one frame uniform per pack, apart from its post passes
`NativePack` holds what a running pack needs: the `frame` every stage
reads (`NativeFrame`, written once a frame before the world draws), its
post passes and the timer of its passes. `NativePost` no longer owns the
frame buffer or the timer, so the sky stage can read the same frame and
be timed by the same timer. No change in what draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:42:46 +01:00
Ludovic Marie d31832eaf4 Merge pull request #1996 from ludovic111/perf/reach-planning
🤖 Agent: merged main in. The old head's parity failure was server_dimension_players, which passes on main's parity-main. tests/test_parity_reach.py OK.
2026-10-26 08:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 6c3473c6e0 Official mod: Minimap
The minimap extra as a mod (mods/official/minimap): the extra's five
options on its settings page, kept in its storage with the extra's values
(corner, size, shape, zoom, rotate), and the map drawn by the game
through render.set-minimap, so it looks and costs the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:40:03 +01:00
Ludovic MarieandClaude Opus 5.5 e52f0fdcc2 Merge main into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:39:51 +01:00
Ludovic Marie 3f5a323fd9 Merge pull request #2026 from ludovic111/feat/shader-pass-times
🤖 Agent: verified: pr_ready --quick green, 32/32 affected harnesses pass locally, CI verify green, paired A/B shows no frame cost without a pack. F3 now shows a shader pack's GPU time per pass, for both pack kinds.
2026-10-26 08:33:05 +01:00
Ludovic MarieandClaude Opus 5.5 24ab642e7b Mod API: render.set-minimap, a map the game keeps in a corner
What the official Minimap mod needs: a mod picks the corner, side, scale,
shape and turning, and the game draws the map as the minimap extra does,
from the same tiles (vanilla's map colours, the floor under roofs) within
its own frame budget. One map at a time, the first loaded mod's. A new
function and type, so mods built before it keep loading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:20:30 +01:00
Ludovic Marie c58af30ca6 Merge pull request #2043 from ludovic111/ci/parity-main-runner-comment
🤖 Agent: comment only; the runner labels were moved via the API.
2026-10-26 08:20:07 +01:00
Ludovic MarieandClaude Opus 5.5 6ace65fc18 ci: parity-main's runner comment says where it runs now
ludovics-mac-mini-2 holds only the horizon-parity-main label; the release
runner no longer has it, since a release waited behind each new
parity-main run there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:11:46 +01:00
Ludovic MarieandClaude Opus 5.5 108ced5793 launcher: a Shader packs tab that installs Horizon's own shader packs
The Mods page gets a fourth kind, Shader packs, which searches Horizon's
registry and installs a `.hshader` into `shaderpacks/` once
`index::check_file` has run the game's own `horizon_shader_api::check`.
Modrinth and CurseForge answer that tab with a pointer to the registry:
their shader packs are the shaders/ format and are not offered.

The sample index lists a shader pack (`glow`, made with
`horizon-shader new` and `pack`), so the registry build, check_file and
the launcher's install are tested on a real file. A file of the same
name the registry didn't put in place is no longer called a mod.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:41:24 +01:00
Ludovic MarieandClaude Opus 5.5 a1f1501305 launcher: Cargo.lock follows horizon-mods' shader check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:11:30 +01:00
Ludovic Marie cbdbf068bf Merge main into feat/native-shaders-registry 2026-10-26 06:59:07 +01:00
Ludovic MarieandClaude Opus 5.5 70b832d658 Options > Horizon Settings > Shader Packs: pick a pack of either kind
The page lists the packs in shaderpacks/: Horizon's own, marked (Horizon) and
named by their manifest, then packs in the shaders/ format. One runs at a
time: clicking a pack picks it, clicking the picked one turns it off, and
Open Pack Folder opens the folder. The pick is saved in horizon-graphics.txt
under a new key (pickedShaderPack), so the shaderPack value older versions
saved stays ignored. A new pick drops the running pack and loads the new one
in the next frame; HORIZON_SHADER_PACK still wins, for development.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:49:41 +01:00
Ludovic MarieandClaude Sonnet 5.5 c1814283e0 Merge origin/main into fix/creative-drop-on-server
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:49:26 +01:00
Ludovic MarieandClaude Sonnet 5.5 4512105e85 Merge origin/main into fix/trapped-chest-signal-barrel-open
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Opus 5.5 c3763ff1b3 F3 shows the shader pack's GPU time per pass, for either kind of pack
While the F3 overlay is open, the running pack's passes are timed on the GPU,
a shaders/ pack's or a native pack's, and the overlay lists the pack's total
and its costliest passes (the median of the last 15 timed frames). The timer
never waits: a timed frame's stamps are resolved once the GPU has finished it
and read back once mapped, then the next frame is timed. A native pack's first
pass counts from a mark that ends with the world's draws, so it does not carry
the time it waited for them. HORIZON_SHADER_PACK_TIMING still logs the medians.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 0958cb297a Merge origin/main into fix/portal-effect-on-server-clients
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:55 +01:00
Ludovic MarieandClaude Opus 5.5 a6c8e9070a Zoom moved to the mod browser: remove the built-in zoom
The official Zoom mod (mods/official/zoom) does what the extra did. The
extra's zoom, its key and wheel hooks and its Extras rows go; the camera
keeps a mod's zoom (render.set-zoom). A player who had zoom on gets the
mod installed and turned on at the first start, with the starting zoom,
smooth zoom and slower turning carried into its storage and a changed
key into horizon-mods.txt, and one chat line. horizon-extras.txt keeps
the old keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:42:14 +01:00
Ludovic Marie 886ecd9b20 Merge remote-tracking branch 'origin/main' into perf/reach-planning 2026-10-26 06:13:02 +01:00
Ludovic MarieandClaude Opus 5.5 d113131f7e Merge feat/official-zoom-mod into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:55:10 +01:00
Ludovic MarieandClaude Opus 5.5 3df60c21c2 tests: compare resolved paths in the prepared-report fallback test
macOS temp dirs live under /var, a symlink to /private/var, so the
resolved report path never equalled the unresolved fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:28:30 +01:00
Ludovic Marie ab92ab346c Merge remote-tracking branch 'origin/main' into cg15/1909 2026-10-26 05:06:59 +01:00
Ludovic MarieandClaude Sonnet 5.5 0bfb011ee2 Merge feat/structure-saved-starts into feat/lithostitched-processors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:38:00 +01:00
Ludovic MarieandClaude Opus 5.5 2631eca00b Drop the migration's validation ledger from the docs
It records an agent's runs and diagnoses, which the pull request and its
branch history already keep; docs/ is for how the project works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:32:44 +01:00
Ludovic MarieandClaude Opus 5.5 7b0192118e parity: choose harnesses faster (shared parse cache, early stop, time budget)
- The reach parse cache lives in the shared parity folder, keyed by content,
  so a new worktree starts warm (written atomically, other worktrees'
  entries kept within bounds).
- A harness's reach search stops as soon as it reaches a changed file that
  differs in more than its `mod` declarations; partial searches are not
  memoized. Declaration diffs are memoized (one git show per file).
- A harness whose changed dependency is outside crates/ is picked without a
  search (narrowing always keeps those).
- After PARITY_REACH_BUDGET seconds (default 180) the remaining harnesses
  count every source their probes link.
- crate_of is cached; no pathlib in the hot paths.

Same 79 harnesses on the 19-file diff from contrib-gate-16; CPU 19.9 s cold /
16.6 s warm before, 13.1 s cold / 9.9 s warm after (Mac mini under load).

parity.yml: skip the prepare_output check for heads made before #1947 (no
OUTPUT_VERSION, no tool): the job runs main's workflow on the head's files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:13:09 +01:00
Ludovic MarieandClaude Opus 5.5 bb3a289caa Read the click's changed slots for the machine's setChanged
The menu op already lists the slots the click changed; a container slot
among them is the Slot.setChanged that saves the block entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:07:48 +01:00
Ludovic MarieandClaude Opus 5.5 7dfd4c6280 Share the player's water and lava tags with dropped items
The item's fluid world had its own copy of the FluidTags.WATER/LAVA
membership the player's immersion uses; motion now exports that one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:04:20 +01:00
Ludovic MarieandClaude Opus 5.5 31eecdc271 Compute a firework's blast distance in floats, as Entity.distanceTo does
Entity.distanceTo casts each offset to float and sums the squares in
floats; taking the double distance and rounding it gave a slightly
different damage at the edge of rounding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:03:25 +01:00
rdbtCVSandCodex gpt-6.1-sol 2d088739e5 Fix dropped items following fluid currents
Sample item immersion and apply shared entity currents before and after movement, matching vanilla tick order. Cover item-sized fluid scans and flowing-water movement.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-10-25 23:40:17 +00:00
Kacpherek a6548089c4 Count the death roll in ticks so the dead camera eases instead of shaking
The camera's death roll and zoom (GameRenderer.bobHurt, Camera.modifyFovBasedOnDeathOrFluid) read deathTime + partialTick. Horizon derived deathTime from the wall clock since the death message, which the server thread path never set: there deathTime stayed 0, so every frame used 0 + partialTick and the view rolled from 0 to 0.2 degrees and snapped back twenty times a second (with a matching zoom pulse) instead of settling at vanilla's 3.6 degrees. In frame, the wall-clock ticks were not in step with the frame's partial tick either.

deathTime is now the tick counter LivingEntity.tickDeath keeps: the in-frame tick already counted it, and the server thread path now resets it when the death screen opens and counts it once per client tick. horizon-ctl state reports it as player.death_time.
2026-10-26 00:20:39 +01:00
Kacpherek bbc57aa838 Save furnace, brewing stand and crafter edits made through a server menu
A server player's clicks in a furnace, brewing stand or crafter menu wrote
the block entity's slots but never marked it changed, so the chunk was
not saved: an idle furnace (nothing burning, so its own tick changes
nothing) given or emptied through its menu rolled back on reopen or
chunk unload, losing or duplicating the items. Comparators kept the old
reading too.

When the click changed the container's slots, call container_changed
(BlockEntity.setChanged, reached through Slot.setChanged in vanilla's
AbstractContainerMenu.doClick and quickMoveStack), as chest-like menus
already do.
2026-10-26 00:20:26 +01:00
Kacpherek d143666e46 Mark a server's player dead while its arrival in a level is loading
A server's player on a trip into another level was skipped by
tick_guests before its zero-health body was marked dead, so a death
during the arrival (a /kill, damage) only counted once the destination
had loaded. The client already showed the death screen, and a Respawn
press in between was refused (guest_respawn wants a dead player) and
then lost, leaving the player on the death screen until a second press.

LivingEntity.hurt calls die at once, loaded or not: the death half of
the guest tick is now its own guest_die, which also runs for a player
on a trip.
2026-10-26 00:19:55 +01:00
Kacpherek c7432c21e1 Check a server player's moves with the box of its pose
A player sprint-swimming up under a ceiling was sent back every tick on the server thread (and for LAN guests): the server never updated the player's pose, and handlePlayerPositionChange's collision check always used a standing 1.8-block box, so the 0.6-block swimming box the client moved with looked like it entered the ceiling. Player.tick ends with updatePlayerPose, and the check uses getBoundingBox, so run updatePlayerPose in the server player's tick and check moves with the pose's dimensions. Fixes #1899.
2026-10-26 00:17:03 +01:00
Kacpherek 2c0d9cc30e Tick End gateways' server copies on a server without a window
A used End gateway never worked again on the server thread or on a
dedicated server. TheEndGatewayBlockEntity's server ticker (portalTick)
ran only inside the client block-entity pass, which returns at once when
there is no renderer, so the 40 tick cooldown that
EndGatewayBlock.entityInside starts (triggerCooldown) never ran out.

A Game without a renderer now runs portalTick on the gateways it keeps a
server copy of, in block-ticking chunks as Level.tickBlockEntities does,
and sends triggerCooldown's block event every 2400 ticks of age.
2026-10-26 00:16:50 +01:00
Kacpherek f988ed417a Drop a pending server trip when its player is teleported within the level
A server's player sent into another level waits on a trip until the
chunks around its target load, and the server leaves its body alone and
skips its portal checks until then. A /tp within the level while the
arrival was still loading left that trip behind: its target lost its
loading tickets and never finished, so Nether, End and gateway portals
stopped taking the player until they went back to the abandoned spot.

ServerPlayer.teleport within a level places the player at once, so the
pending arrival is now abandoned. server-dimension-players gains the
case: sent far into the Nether, teleported back near the origin before
the far chunks load, then an End portal there.
2026-10-26 00:15:54 +01:00
Kacpherek 21019afc2b Vehicle storage menus edit the vehicle's own items
A chest minecart, hopper minecart or chest boat's menu edits a stand-in
container, which is meant to be copied back to the vehicle when the menu
closes. close_container took the open container before calling
close_cart_container, which then found nothing open and copied nothing:
items taken out came back on the next open, and items put in vanished.

Copy the stand-in back from the key close_container took. A server
player's clicks in such a menu copy it back at once, since the menu is
the vehicle's own inventory (ContainerEntity, AbstractMinecartContainer
and ChestBoat implement the container the menu reads and writes).
2026-10-26 00:14:27 +01:00
Kacpherek 785e83feeb Firework explosions hurt nearby players, not only the boosted one
FireworkRocketEntity.dealExplosionDamage hurts every LivingEntity within 5
blocks that the rocket can see (its feet or its middle, ClipContext
COLLIDER with no fluids), besides the entity it is attached to. Horizon only
hurt the attached player and the nearby mobs, so a rocket bursting next to a
player on foot did nothing to them, in both single player modes.

The player (unless it is the attached one) and every guest in the level now
take damageAmount * sqrt((5 - distanceTo) / 5) as fireworks damage, with the
armor wear of hurtServer. distanceTo is a float, as vanilla computes it, for
the mobs too.
2026-10-26 00:14:19 +01:00
Kacpherek 9aa6d7723f Shulker bullets home on the guest the shulker targets
A mob that sees a guest (a LAN guest, or the player of the server thread)
holds Who::Player as its target, and what it does to that player runs with
the guest swapped in. A shulker's bullet, though, keeps the target it was
fired with until it lands, and the projectiles tick outside that swap:
there Who::Player is the host's own player, so on a server thread the
bullet found no target, stopped homing and fell onto the blocks.

The bullet now carries the guest's entity id, as ShulkerBullet.finalTarget
holds the targeted entity in vanilla (ShulkerAttackGoal.tick passes
Shulker.getTarget()), so it homes on the guest among the projectiles'
targets and its hit runs as that guest: 4 damage and Levitation.
2026-10-26 00:10:54 +01:00
Kacpherek e50a4c6443 Play no place sound when a block item placement is refused
Clicking with a torch or flower into a space that is already taken (or where the block cannot stand) made the server-thread client play the block's place sound when the server resent the refused target. BlockItem.place returns FAIL before playSound when canPlace() is false, and the client only hears its own predicted placement, so clear the pending place sound when no placement was predicted.
2026-10-26 00:06:53 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 bd09f8b03e Migrate player integration coverage to server-thread hosting
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-25 19:36:11 +00:00
Ludovic MarieandClaude Opus 5.5 dab13669f5 Creative screen drops reach the server: SetCreativeModeSlot(-1)
A stack picked from a creative tab exists only on the client. Thrown out of
the creative screen as a server's client, it went out as a ContainerClick to
slot -999, which the server had no carried stack for, so nothing dropped. The
client now sends handleCreativeModeItemDrop's SetCreativeModeSlot(-1, stack),
and the server drops it from the player's hand, with vanilla's drop spam
throttle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:53:09 +01:00
Ludovic MarieandClaude Opus 5.5 df8b23637e horizon-mods: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:09:21 +01:00
Ludovic MarieandClaude Opus 5.5 390da26cf4 horizon-mod-registry: the form's test sends a shader pack through the game's check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:38:54 +01:00
Ludovic MarieandClaude Opus 5.5 1285247230 Registry: shader packs are checked by the game's own shader check, and open
The `shader` kind's check (index::inspect) reads the .hshader and runs
horizon_shader_api::check, the same check the game runs before it loads a
pack, so the registry's CI (`cargo horizon-mod check` / `registry build`)
refuses a pack the game would refuse, with each problem at its file and line.
Its facts are the manifest's id, version and shader API line. The kind's
files are .hshader, `cargo horizon-mod check` recognises them, and
registry::open takes shader submissions. Installing one from the launcher
stays refused (check_file) until the launcher installs shader packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:31:05 +01:00
Ludovic MarieandClaude Opus 5.5 bcd42f50e9 cargo horizon-mod test: server mods' scripts
A test script drives a server mod as a server would: join = "Alex" brings a
player in (ids stable per name, so a rejoin is the same player), as = "Steve"
picks who acts (the first player on otherwise), and player, chat, command,
break and place are that player's doing; leave = "Alex" lets them go. Server
expectations: told (every players.tell, "Name: text") and broadcast; feedback
and error read the reply to the player who typed the command. Ticks call both
kinds of tick hooks, so one script format serves client and server mods.

The welcome example gets two scripts (greeting, a newcomer announced once
even after a reload, /online across dimensions, /mined per player), run by
the sandbox tests with coords-hud's; docs/mods.md lists the server keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:55:55 +01:00
Ludovic MarieandClaude Opus 5.5 c9f554d1c4 Mod API 0.3 (experimental): server mods' contract, runtime and SDK
A server mod runs where the world is simulated. Its manifest says side =
"server" and api = "0.3" (only a server mod may), and its component targets
the new WIT world horizon-server-mod:

- imports log and storage as client mods do, and the new capability players:
  online() (id, name, position, dimension, game mode, health), tell(id, text),
  broadcast(text), 8 lines per call shared with chat;
- exports server-events: init, tick, player-joined, player-left, chat, command
  (with the player who typed it; its feedback goes back to that player),
  block-broken and block-placed (with the block state as text).

horizon-mods: a second bindgen world sharing log and storage with the first;
a running mod holds either kind of hooks; Mods::load_for(Runs) starts only the
mods a game runs (the window client mods, the world's server server mods,
in-frame single player both) and marks the others Elsewhere. Client and server
commands are owned separately. The game passes Runs::Client for now, so
nothing changes in game until its server code calls these hooks.

The SDK gains horizon_mod_sdk::server (ServerMod, Registration,
export_server_mod!), the example server mod welcome (greets, announces
newcomers kept in storage, /online, /mined), and cargo horizon-mod check's
trial run plays a server mod with a player joining, chatting, typing its
commands and leaving. The manifest refuses client capabilities on a server
mod and players on a client mod. The generated reference, the guide, the
portal and the stability table describe it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:51:15 +01:00
Ludovic MarieandClaude Opus 5.5 0f2a497f02 Trapped chests give their signal, barrels show they're open
TrappedChestBlock.getSignal is the chest's opener count (at most 15);
TrappedChestBlockEntity.signalOpenCount updates the blocks around it and
below it when the count changes. GameLevel answered 0 for every trapped
chest. BarrelBlockEntity's openers set open=true for the first one and
open=false when the last one leaves; nothing set it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:09:16 +01:00
Ludovic MarieandClaude Opus 5.5 319c7fcaee A server's client shows the nether portal effect
NetherPortalBlock.entityInside runs on the client's own level too: its
setAsInsidePortal drives LocalPlayer.handlePortalTransitionEffect (the
purple overlay, block.portal.trigger). The remote motion loop dropped
MotionEvent::InsidePortal, so single player on the server thread, LAN
guests and dedicated servers' players went through portals without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:58:51 +01:00
Ludovic MarieandClaude Opus 5.5 eb6c81945a Lithostitched processors: unit tests rewritten from the spec
The previous tests of litho.rs are deleted unread (clean-room ruling of
2026-10-25). The new ones build their own block registry from a facts
table and run each processor through process_list against a one-block
world: block_swap's property copy (CTOV's beetroots), references in
order and discard_input, the probe pack's nested condition with else,
apply_random per piece and per block, set_block's providers and its
block entity modifier.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:29:53 +01:00
Ludovic MarieandClaude Opus 5.5 d89fb16f60 Structure blocks harness: CTOV's known differences
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:06:14 +01:00
Ludovic MarieandClaude Opus 5.5 c43f4bdb2b Lithostitched's processors as the game's own types; replace the old Lithostitched conversion
Clean-room implementation from spec-lithostitched.md §3-§5 and spec-ctov.md §4.

The game reads Lithostitched's processors as its data writes them
(processor/litho.rs): block_swap (only the properties the new block can
have: ripe wheat becomes ripe carrots, beetroot keeps its default age),
reference, apply_random, condition (with else, one processor or a list),
set_block (simple and weighted providers, preserve_state, block entity
modifier) and discard_input; the conditions all_of, any_of, not,
random_chance, true and matching_blocks (input or location, properties).
Their random follows random_mode per block or per piece; Lithostitched's own
seeding is open (OPEN-L6), so a positional random of the world seed and the
processor stands in (impl-questions IQ-7).

horizon-port-pack's new lithostitched module carries the processors as they
are, with the processors nested in them carried, drops those the game cannot
read (lists named by tag, other providers) as Java processors, turns CTOV's
modular_compat into a reference when its mod is ported along (else nothing),
maps lithostitched:jigsaw to vanilla's jigsaw (vanilla layouts, the decision
on OPEN-L5), unwraps limited/guaranteed/delegating elements to their
delegate (limits counted as lost until the next PR) and writes
add_template_pool_elements modifiers as horizon:add_to pools when their
NeoForge load conditions pass.

Deleted unread: legacy_structure_mods/lithostitched.rs and horizon-world's
structure/processor/lithostitched.rs. Blind edits to the legacy folder,
located by exact text or the compiler's error position: its module line
removed, dead_code allowed on ctov (its modular compat is unused now) and on a
test helper, and ctov's modular compat test ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:51:17 +01:00
153 changed files with 7756 additions and 2485 deletions

No files matched your search

+13 -7
View File
@@ -8,7 +8,7 @@ name: nightly audit
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
@@ -31,14 +31,19 @@ jobs:
audit:
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
runs-on: [self-hosted, horizon-game, macOS]
timeout-minutes: 150
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
steps:
- name: 03:00 in Paris
- name: 03:07 in Paris
id: when
env:
SCHEDULE: ${{ github.event.schedule }}
run: |
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
offset=$(TZ=Europe/Paris date +%z)
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
@@ -66,7 +71,8 @@ jobs:
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
+4 -4
View File
@@ -23,10 +23,10 @@ env:
jobs:
parity-main:
# The Mac mini's release runner (label added 2026-10-26), not the two `horizon-game` runners: behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and releases wait for it. Releases need it green
# first, so the two never compete. Not the desktop: a job slowed down because the maintainer came back could time
# out, and a failure here bisects and comments on merges.
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
+8 -1
View File
@@ -52,7 +52,14 @@ jobs:
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
fi
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
+4 -4
View File
@@ -66,7 +66,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -156,7 +156,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
@@ -164,8 +164,8 @@ when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
Generated
+39
View File
@@ -1437,12 +1437,14 @@ dependencies = [
name = "horizon-mods"
version = "0.1.0"
dependencies = [
"horizon-shader-api",
"log",
"minisign-verify",
"serde",
"serde_json",
"sha2",
"toml",
"wasmparser",
"wasmtime",
"wit-component",
"wit-parser",
@@ -2082,6 +2084,13 @@ dependencies = [
"libc",
]
[[package]]
name = "minimap"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
@@ -4140,6 +4149,28 @@ dependencies = [
"wit-parser",
]
[[package]]
name = "wast"
version = "254.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "febc59cde513fc8f39b8ffd487893723c555fdd024e2e3dc7d165181e6397ea0"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7db96351e51a3a1bbe6812f029e1100b4d9a5e02161fb63104bd2271fc847e7e"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -4287,6 +4318,13 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "welcome"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -4998,6 +5036,7 @@ dependencies = [
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wat",
"wit-parser",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/zoom", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
+61 -27
View File
@@ -1,10 +1,11 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
@@ -46,6 +47,7 @@ impl Report {
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"api_level": self.facts.api_level,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
@@ -69,6 +71,8 @@ impl Report {
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if self.kind == Kind::Shader {
out += &format!(", shader API {}", f.api);
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
@@ -84,6 +88,9 @@ impl Report {
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
if let Some(level) = f.api_level {
out += &format!(" needs mod API level {level} or newer\n");
}
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
@@ -113,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
_ => None,
}
}
@@ -191,40 +199,66 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
});
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Feedback { text, error } => {
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
@@ -285,7 +319,7 @@ mod tests {
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("shader packs open"), "{e}");
assert!(e.contains("horizon-shaders.toml"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
"font",
"packs",
"mods",
"shader-packs",
"graphics",
"graphics-advanced",
"extras",
@@ -610,12 +610,17 @@ mod tests {
#[test]
fn unreachable_games_fail_with_status_3() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
let home =
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
std::fs::create_dir_all(&home).unwrap();
let mut s = Session::new(home.join("nobody.sock"), None);
s.home = home.clone();
let f = s.run(&["state".to_owned()]).unwrap_err();
assert_eq!(f.status, UNREACHABLE);
assert_eq!(f.reply["kind"], "connection");
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
assert_eq!(f.status, USAGE);
std::fs::remove_dir_all(home).unwrap();
}
#[test]
@@ -84,6 +84,33 @@ impl Displayer {
}
}
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
/// pass's median milliseconds, in the order they run).
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
const SHOWN: usize = 8;
if passes.is_empty() {
return Vec::new();
}
let total: f32 = passes.iter().map(|p| p.1).sum();
let mut lines = vec![format!(
"Shader pack GPU: {total:.2} ms in {} passes",
passes.len()
)];
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
lines.extend(
costliest
.iter()
.take(SHOWN)
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
);
if costliest.len() > SHOWN {
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
}
lines
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -337,4 +364,15 @@ mod tests {
(1.0, [640, 360])
);
}
#[test]
fn shader_pass_lines_list_the_costliest_passes_first() {
assert!(shader_pass_lines(&[]).is_empty());
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
let lines = shader_pass_lines(&passes);
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
assert_eq!(lines[1], "p9: 9.00 ms");
assert_eq!(lines[8], "p2: 2.00 ms");
assert_eq!(lines[9], "2 more: 1.00 ms");
}
}
+9 -7
View File
@@ -10,6 +10,8 @@ use crate::{
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
mod fluids;
#[derive(Clone, Debug)]
pub struct ItemEntity {
pub health: i32,
@@ -165,18 +167,16 @@ impl ItemEntity {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
) -> bool {
self.ticks += 1;
self.previous = self.position;
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
self.pickup_delay -= 1;
}
let eye = world.block(
self.position.x.floor() as i32,
(self.position.y + 0.1).floor() as i32,
self.position.z.floor() as i32,
);
let in_water = facts.get(eye).fluid == Fluid::Water;
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
let (water_height, lava_height) = self.update_fluids(world, fluids);
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
self.velocity = Vec3::new(
@@ -184,7 +184,7 @@ impl ItemEntity {
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
} else if facts.get(eye).fluid == Fluid::Lava {
} else if lava_height > f64::from(0.1f32) {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
@@ -245,6 +245,8 @@ impl ItemEntity {
}
}
self.age += 1;
// ItemEntity.tick refreshes immersion and pushes again after moving.
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
@@ -0,0 +1,216 @@
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
use super::ItemEntity;
use crate::{
motion::{FluidResources, FluidTags, LAVA, WATER},
world::World,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
use horizon_world::{
entity_fluid::{
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
FluidSections, FluidTagId,
},
fluid::{FluidHeightWorld, FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
registry::BlockStateId,
};
type Error = std::convert::Infallible;
struct ItemFluidWorld<'a> {
item: &'a mut ItemEntity,
world: &'a World,
fluids: &'a FluidResources,
}
impl ItemEntity {
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = ItemFluidWorld {
item: self,
world,
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
);
(water_height, lava_height)
}
}
impl FluidMembership for ItemFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
FluidTags.fluid_in_tag(kind, tag)
}
}
impl CurrentTarget for ItemFluidWorld<'_> {
type Error = Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> Result<Vec3, Error> {
Ok(self.item.velocity)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
let v = self.item.velocity;
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
if delta.is_finite() && sum.is_finite() {
self.item.velocity = sum;
}
Ok(())
}
}
impl FluidInteractionWorld for ItemFluidWorld<'_> {
type Error = Error;
type Chunk = ();
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
Ok(Some(
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
})
}
fn bounding_box_min_y(&self) -> f64 {
self.item.position.y
}
fn block_x(&self) -> i32 {
self.item.position.x.floor() as i32
}
fn eye_y(&mut self) -> Result<f64, Error> {
Ok(self.item.position.y + f64::from(0.2125f32))
}
fn block_z(&self) -> i32 {
self.item.position.z.floor() as i32
}
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.height_at(self, [p.x, p.y, p.z])
}
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.camera_height_at(self, [p.x, p.y, p.z])
}
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
flow_at(self, f, p)
}
}
impl FluidHeightWorld for ItemFluidWorld<'_> {
type Error = Error;
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
}
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
let state = self.world.block(p[0], p[1], p[2]);
Ok(self.fluids.sturdy(state, Direction::Down))
}
}
impl FluidFlowWorld for ItemFluidWorld<'_> {
type Error = Error;
type Block = BlockStateId;
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
Ok(self.world.block(p.x, p.y, p.z))
}
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
self.fluids.blocks_flow(s)
}
fn is_ice_class(&self, s: BlockStateId) -> bool {
self.fluids.ice(s)
}
fn is_face_sturdy(
&mut self,
s: BlockStateId,
_: BlockPos,
d: Direction,
) -> Result<bool, Error> {
Ok(self.fluids.sturdy(s, d))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
id: 1,
health: 5,
fire_ticks: 0,
stack: crate::items::ItemStack {
item: "minecraft:sugar_cane".into(),
count: 1,
damage: 0,
extra: None,
},
thrower: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
};
let mut still = item.clone();
assert!(still.tick(&world, &facts, &collision, false, &fluids));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(&world, &facts, &collision, false, &fluids);
}
assert!(carried.position.x > at.x + 0.5);
}
}
+18 -290
View File
@@ -1,4 +1,4 @@
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
//! Horizon's optional extras (minimap, lighting, inventory tools): their
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
@@ -6,17 +6,8 @@
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the zoom key waits for a key.
pub const ZOOM_KEY: &str = "horizon.zoom";
/// The same for the sort key.
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// Zoom levels the default cycles through, and the wheel's limits.
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
pub const ZOOM_MIN: f32 = 1.25;
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
pub const ZOOM_MAX: f32 = 10.0;
/// Each wheel step multiplies the zoom by this.
const ZOOM_STEP: f32 = 1.25;
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -184,7 +175,7 @@ impl Extras {
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
if let Ok(v) = value.parse::<u8>() {
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
e.zoom_level = v.clamp(2, 10);
}
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
@@ -270,11 +261,10 @@ impl Extras {
bool_text(self.discord_presence),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
ZOOM_KEY => self.zoom_key = value,
SORT_KEY => self.sort_key = value,
_ => return false,
}
@@ -291,87 +281,12 @@ impl Extras {
}
}
/// The zoom key's state: held or not, and the zoom the wheel has set.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Zoom {
held: bool,
factor: f32,
}
impl Default for Zoom {
fn default() -> Self {
Self {
held: false,
factor: 1.0,
}
}
}
impl Zoom {
/// The key went down: start at the default level.
pub fn press(&mut self, level: u8) {
self.held = true;
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
}
pub fn release(&mut self) {
self.held = false;
}
/// A press or release of the zoom key. Only a press while `playing` (no
/// screen or text field open) zooms; anywhere else the key belongs to
/// the screen, and the zoom lets go.
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
if !pressed || !playing {
self.release();
} else if !repeat {
self.press(level);
}
}
pub fn held(&self) -> bool {
self.held
}
/// Wheel steps while held (positive zooms in); false when not zooming.
pub fn scroll(&mut self, steps: f32) -> bool {
if !self.held {
return false;
}
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
true
}
/// The zoom in effect: 1 when the key is up or the spyglass is in use
/// (scoping keeps vanilla's spyglass view).
pub fn factor(&self, scoping: bool) -> f32 {
if self.held && !scoping {
self.factor
} else {
1.0
}
}
}
/// Vanilla key mappings bound to the zoom key. The key still reaches them
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
/// says so.
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
if e.zoom_key == "key.keyboard.unknown" {
return Vec::new();
}
crate::keybinds::MAPPINGS
.iter()
.map(|m| m.0)
.filter(|name| keys.get(name) == e.zoom_key)
.collect()
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
}
/// The mouse turning scale while zoomed.
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
if lower_sensitivity && zoom > 1.0 {
1.0 / f64::from(zoom)
} else {
1.0
}
}
/// Frame times over the last few seconds: frames per second, the minimum
/// and the 1% low, recomputed twice a second.
pub struct FpsCounter {
@@ -463,17 +378,6 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
Zoom,
ZoomKey,
ZoomLevel,
ZoomSmooth,
ZoomSensitivity,
Minimap,
MinimapCorner,
MinimapSize,
MinimapShape,
MinimapZoom,
MinimapRotate,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -492,19 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
Ok(ZoomLevel),
Ok(ZoomSmooth),
Ok(ZoomSensitivity),
Err("Minimap"),
Ok(Minimap),
Ok(MinimapCorner),
Ok(MinimapSize),
Ok(MinimapShape),
Ok(MinimapZoom),
Ok(MinimapRotate),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -514,37 +405,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
]
}
/// "1 block", "2 blocks", "½ block".
fn block_count(n: f32) -> String {
if n < 1.0 {
"½ block".into()
} else if n == 1.0 {
"1 block".into()
} else {
format!("{n} blocks")
}
}
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
let i = all.iter().position(|v| *v == current).unwrap_or(0);
let n = all.len();
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
}
impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
Item::ZoomSmooth => "Smooth zoom",
Item::ZoomSensitivity => "Slower turning",
Item::MinimapCorner => "Corner",
Item::Minimap => "Minimap",
Item::MinimapSize => "Size",
Item::MinimapShape => "Shape",
Item::MinimapZoom => "Zoom",
Item::MinimapRotate => "Turn with the player",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
@@ -559,12 +424,10 @@ impl Item {
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | MouseTweaks | Sort | AutoReconnect
| NoAi | AgentControl | DiscordPresence => {
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
})
@@ -576,17 +439,6 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
Item::ZoomSmooth => "Eases in and out like the spyglass.",
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
Item::MinimapCorner => "Overlays in one corner stack.",
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
Item::MinimapSize => "How big the map is on screen.",
Item::MinimapShape => "Square or round.",
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
Item::MinimapRotate => "Forward is up; off keeps north up.",
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
@@ -617,8 +469,8 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom => Some(Cost::None),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
@@ -628,11 +480,6 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
Item::Minimap => e.minimap,
Item::MinimapRotate => e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
@@ -646,55 +493,32 @@ impl Item {
/// The value of the other items, as their button shows it.
pub fn value(self, e: &Extras) -> String {
match self {
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::ZoomLevel => format!("{}x", e.zoom_level),
Item::MinimapCorner => e.minimap_corner.label().into(),
Item::MinimapSize => MINIMAP_SIZES
.iter()
.find(|s| s.0 == e.minimap_size)
.map_or("Medium", |s| s.1)
.into(),
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
},
}
}
/// A click (`back` for the right button). The zoom key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
/// A click (`_back` for the right button: every item left is a switch,
/// so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, _back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
Item::Minimap => e.minimap = !e.minimap,
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
Item::MinimapSize => {
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
e.minimap_size = next(&sizes, e.minimap_size, back);
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::ZoomKey | Item::SortKey => {}
Item::SortKey => {}
}
}
/// The key name the options screen waits on for a key item.
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::ZoomKey => Some(ZOOM_KEY),
Item::SortKey => Some(SORT_KEY),
_ => None,
}
@@ -752,43 +576,7 @@ mod tests {
}
#[test]
fn default_zoom_key_is_free_in_vanilla() {
let key = Extras::default().zoom_key;
assert!(crate::keybinds::key_code(&key).is_some());
assert!(
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
"{key} is a vanilla binding"
);
}
#[test]
fn zoom_is_vanilla_unless_held_and_not_scoping() {
let mut z = Zoom::default();
assert_eq!(z.factor(false), 1.0);
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
z.press(4);
assert_eq!(z.factor(false), 4.0);
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
assert!(z.scroll(1.0));
assert!((z.factor(false) - 5.0).abs() < 1e-5);
for _ in 0..40 {
z.scroll(1.0);
}
assert_eq!(z.factor(false), ZOOM_MAX);
for _ in 0..80 {
z.scroll(-1.0);
}
assert_eq!(z.factor(false), ZOOM_MIN);
z.release();
assert_eq!(z.factor(false), 1.0);
z.press(2);
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
}
#[test]
fn zoom_target_eases_like_the_spyglass() {
fn a_mods_zoom_eases_like_the_spyglass() {
// Camera.tickFov halves the distance to the target each tick.
let mut camera = crate::camera::Camera::default();
camera.tick(None, 1.0);
@@ -812,55 +600,6 @@ mod tests {
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
}
#[test]
fn the_zoom_key_only_zooms_while_playing() {
let mut z = Zoom::default();
// In chat, a sign, a text field or any screen: no zoom.
z.key(true, false, false, 4);
assert!(!z.held());
z.key(true, false, true, 4);
assert!(z.held());
// A screen opening while held lets go (its key presses are the screen's).
z.key(true, true, false, 4);
assert!(!z.held());
// Key repeats do not restart a zoom the wheel changed.
z.key(true, false, true, 4);
z.scroll(1.0);
z.key(true, true, true, 4);
assert!((z.factor(false) - 5.0).abs() < 1e-5);
z.key(false, false, true, 4);
assert!(!z.held());
}
#[test]
fn zoom_key_conflicts_name_the_vanilla_mappings() {
let mut e = Extras::default();
let mut keys = crate::keybinds::KeyBinds::default();
assert!(zoom_key_conflicts(&e, &keys).is_empty());
e.zoom_key = "key.keyboard.c".into();
assert_eq!(
zoom_key_conflicts(&e, &keys),
[
"key.saveToolbarActivator",
"key.debug.crash",
"key.debug.copyLocation"
]
);
keys.set("key.drop", "key.keyboard.z");
e.zoom_key = "key.keyboard.z".into();
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
e.zoom_key = "key.keyboard.unknown".into();
keys.set("key.smoothCamera", "key.keyboard.unknown");
assert!(zoom_key_conflicts(&e, &keys).is_empty());
}
#[test]
fn sensitivity_scales_with_zoom() {
assert_eq!(turn_scale(1.0, true), 1.0);
assert_eq!(turn_scale(4.0, false), 1.0);
assert_eq!(turn_scale(4.0, true), 0.25);
}
#[test]
fn fps_counter_reports_rate_minimum_and_low() {
let mut c = FpsCounter::default();
@@ -914,20 +653,9 @@ mod tests {
#[test]
fn items_cycle_through_their_values() {
let mut e = Extras::default();
Item::ZoomLevel.cycle(&mut e, false);
assert_eq!(e.zoom_level, 6);
Item::ZoomLevel.cycle(&mut e, true);
Item::ZoomLevel.cycle(&mut e, true);
assert_eq!(e.zoom_level, 3);
Item::MinimapCorner.cycle(&mut e, true);
assert_eq!(e.minimap_corner, Corner::TopLeft);
Item::Zoom.cycle(&mut e, false);
assert_eq!(Item::Zoom.switch(&e), Some(true));
assert_eq!(Item::ZoomKey.value(&e), "Z");
Item::MinimapZoom.cycle(&mut e, true);
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
Item::MinimapSize.cycle(&mut e, false);
assert_eq!(Item::MinimapSize.value(&e), "Large");
Item::Sort.cycle(&mut e, true);
assert_eq!(Item::Sort.value(&e), "On");
// The minimap's old keys still parse, for the move to its mod.
assert_eq!(
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
1.0
+31 -8
View File
@@ -131,6 +131,7 @@ mod pumpkin;
mod raids;
mod recipe_book;
mod remote;
mod remote_chunks;
mod remote_mob_data;
mod remote_mob_use;
mod remote_objects;
@@ -558,6 +559,8 @@ struct GameLevel<'w> {
/// Where the chunk tickets let blocks tick.
tickets: horizon_client::ticket_levels::Levels,
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
/// Players with each chest open (a trapped chest's signal).
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
}
struct PlayerCollision {
y: f64,
@@ -693,6 +696,10 @@ impl LevelAccess for GameLevel<'_> {
_state: BlockStateId,
_direction: Direction,
) -> i32 {
// TrappedChestBlock.getSignal: the players with it open, at most 15.
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
return (*count).clamp(0, 15);
}
jukebox::jukebox_signals(self.world, self.items, pos).map_or(0, |(signal, _)| signal)
}
fn biome_name(&self, pos: BlockPos) -> Option<&str> {
@@ -1505,7 +1512,7 @@ struct Game<'a> {
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (zoom, FPS counter, keystrokes): `game/extras.rs`.
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
extras: extras::ExtrasState,
/// Horizon mods (`game/mods.rs`), none running unless turned on.
mods: mods::ModsState,
@@ -2203,7 +2210,7 @@ impl Game<'_> {
self.player_drop(thrown);
}
/// `Player.drop`: the statistics and a thrown item entity in front of the player.
fn player_drop(&mut self, thrown: ItemStack) {
pub(super) fn player_drop(&mut self, thrown: ItemStack) {
let Some(eye) = self.eye_position() else {
return;
};
@@ -2265,7 +2272,13 @@ impl Game<'_> {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
let alive = item.tick(
&self.world,
self.res.facts(),
self.collision,
no_physics,
&self.res.motion().fluids,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -2474,6 +2487,7 @@ impl Game<'_> {
spill: &mut self.spill,
tickets: self.tickets.levels(),
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -4186,6 +4200,9 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
if self.agent.hold_server {
return Ok(());
}
self.tick_debug_keys()?;
self.tick_debug_charts();
if self.remote.is_some() {
@@ -5593,6 +5610,8 @@ impl Game<'_> {
self.craft_size = 2;
self.craft_table = None;
if let Some(key) = self.container.take() {
// A cart's or chest boat's items go back to it.
self.close_cart_container(key);
self.container_opener_event(key, false);
self.post_block_game_event(
"minecraft:container_close",
@@ -5632,7 +5651,6 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
self.close_ender_chest();
self.close_cart_container();
}
/// `quickMoveStack` of the open plain menu for a shift-click.
fn inventory_quick_move(&mut self, slot: usize) -> bool {
@@ -6512,7 +6530,11 @@ impl Game<'_> {
use horizon_client::menu::Input;
let button = i32::from(right);
if self.remote.is_some() {
self.remote_throw(right, input);
if self.screen == Screen::Creative {
self.remote_creative_drop(right);
} else {
self.remote_throw(right, input);
}
return;
}
let before = self.carried.clone();
@@ -10496,7 +10518,10 @@ impl Game<'_> {
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
Some(renderer) => horizon_client::spans::time("renderer", || renderer.render(&frame)),
Some(renderer) => {
renderer.set_pass_timing(self.debug);
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
};
// Restore storage even when rendering fails (for example, a lost surface).
@@ -11296,7 +11321,6 @@ impl ApplicationHandler for Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.mod_wheel(d)
&& !self.extras_scroll(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
@@ -11707,7 +11731,6 @@ impl ApplicationHandler for Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return Ok(());
};
self.extras_key(key, event.state == ElementState::Pressed, event.repeat);
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
+5
View File
@@ -300,6 +300,8 @@ struct Glide {
pub(super) struct Agent {
task: Option<Task>,
glide: Option<Glide>,
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
pub hold_server: bool,
}
pub(super) enum Handled {
@@ -479,6 +481,8 @@ impl Game<'_> {
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
"hurt_time": p.health.hurt_animation_time,
// LivingEntity.deathTime while dead (the camera's death roll).
"death_time": self.death_time(),
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
@@ -667,6 +671,7 @@ impl Game<'_> {
});
if let Some(chat) = &self.ui.chat_screen {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
v["menu"] = self.title_value();
@@ -13,7 +13,10 @@ use horizon_client::{
save::number,
skins,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::nbt::Tag;
/// The handle of the local player among a bell's gathered entities.
@@ -86,6 +89,16 @@ impl Game<'_> {
self.lid_openers_changed(other, open);
}
}
_ if self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
@@ -104,15 +117,48 @@ impl Game<'_> {
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let count = self.lid_openers.entry(key).or_default();
*count = (*count + if open { 1 } else { -1 }).max(0);
let count = *count;
if count == 0 {
self.lid_openers.remove(&key);
}
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
// (`TrappedChestBlock.getSignal`), so the blocks around it and
// below it hear of a change.
let state = self.world.block(pos[0], pos[1], pos[2]);
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
{
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
let block = b.block(state);
b.update_neighbors_at(level, ticks, p, block);
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
self.set_bool_prop(
key,
"open",
count > 0,
horizon_world::level::flags::UPDATE_ALL,
);
}
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
let count = self.lid_openers.entry(key).or_default();
let before = *count;
*count = (before + if open { 1 } else { -1 }).max(0);
let after = *count;
if after == 0 {
self.lid_openers.remove(&key);
}
(before, after)
}
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
/// box meets the bell's block box inflated by 48.
@@ -173,6 +219,11 @@ impl Game<'_> {
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
return;
};
// Sorted by position: the order they tick in, and a quick lookup.
@@ -332,6 +383,31 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
/// and every 2400 ticks `triggerCooldown` sends its block event.
fn tick_gateway_servers(&mut self) {
let levels = self.tickets.levels();
let mut positions: Vec<[i32; 3]> = self
.be_anims
.gateway_servers
.keys()
.copied()
.filter(|p| levels.block_ticking_at(p[0], p[2]))
.collect();
positions.sort_unstable();
for pos in positions {
let triggered = self
.be_anims
.gateway_servers
.get_mut(&pos)
.is_some_and(EndGateway::portal_tick);
if triggered {
self.block_entity_event(pos, 1, 0);
}
}
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
@@ -1097,6 +1097,12 @@ impl Game<'_> {
Target::Player => {
// ServerPlayer.teleport: removeVehicle before placing the player.
self.stop_riding();
// A server's player teleported within their level is placed
// there at once: the arrival they were waiting for elsewhere
// in it (whose chunks lose their tickets) is abandoned.
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
+68 -6
View File
@@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
const HELP: &[&str] = &[
"status game, player, streaming and renderer state",
"simulation authoritative projectiles in the owner's level",
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
"stall MILLISECONDS stall the window thread (0..5000), leaving transport alive",
"key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
"release release every key and mouse button",
"mouse left|right|middle down|up|click",
@@ -238,6 +241,47 @@ impl Game<'_> {
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"status" => Ok(Some(self.control_status())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
return Err("stall is limited to 5000 milliseconds".into());
}
std::thread::sleep(Duration::from_millis(millis));
ok()
}
"simulation" => {
self.require_world()?;
let value = if let Some(server) = &self.integrated {
server
.call(|g| {
g.in_owner_level(|g| {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
Ok(Some(value))
}
"server-hold" => {
self.require_world()?;
let held = match arg(1)? {
"on" => true,
"off" => false,
_ => return Err("server-hold on|off".into()),
};
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
self.agent.hold_server = held;
}
ok()
}
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
@@ -295,13 +339,9 @@ impl Game<'_> {
self.debug_key(key, KeyAction::Release);
}
}
// The zoom extra sees the key as the keyboard hook does.
// Mods' keys (capability `input`) see the key as the keyboard
// hook does.
let action = arg(2)?;
self.extras_key(physical, action != "up", action == "repeat");
if action == "tap" {
self.extras_key(physical, false, false);
}
// So do mods' keys (capability `input`).
self.mod_key(physical, action != "up", action == "repeat");
if action == "tap" {
self.mod_key(physical, false, false);
@@ -379,15 +419,19 @@ impl Game<'_> {
match arg(2)? {
"down" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.play_mouse(button, true)?
}
"up" => {
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, false)?
}
"click" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, true)?;
self.play_mouse(button, false)?;
}
@@ -508,10 +552,23 @@ impl Game<'_> {
"mobs" => {
self.require_world()?;
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
if let Some(server) = &self.integrated {
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")?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
}
"hurtlog" => {
self.require_world()?;
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
}
"resume" => {
@@ -612,6 +669,7 @@ impl Game<'_> {
"font" => Page::Font,
"packs" => Page::Packs,
"mods" => Page::Mods,
"shader-packs" => Page::ShaderPacks,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
@@ -625,6 +683,10 @@ impl Game<'_> {
}
self.open_options(self.title.is_some());
self.ui.options.show(page);
if page == Page::ShaderPacks {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
}
ok()
}
"chat-log" => {
@@ -312,6 +312,9 @@ impl Game<'_> {
/// The `mobs` command: nearby mobs with position, health and running goals.
pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
self.control_mobs_at(radius, center)
}
pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value {
let list: Vec<serde_json::Value> = self
.mobs
.iter()
@@ -504,6 +507,17 @@ impl Game<'_> {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
};
// The guests' entity ids by connection, for the shulker bullets fired
// at the guest a mob sees.
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
Some(host) if !guests.is_empty() => host
.hub
.guests
.iter()
.map(|g| (g.conn, g.entity_id as u32))
.collect(),
_ => Vec::new(),
};
let mut guest_events = Vec::new();
let level_random;
{
@@ -578,6 +592,19 @@ impl Game<'_> {
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Who::Player
{
*target = Who::Mob(id);
}
}
}
let mut k = from;
while k < events.len() {
if super::lan_mobs::acts_on_player(&events[k]) {
@@ -415,6 +415,13 @@ impl Game<'_> {
],
);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
});
if !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
// tps: DebugEntryTps for local and remote connections.
if self.player.is_some() {
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
+30 -70
View File
@@ -1,24 +1,22 @@
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
//! the minimap's tiles and image, and their HUD; clicks and frame times for
//! mods (`input.clicks-per-second`, `render.frame-stats`). Each hook does
//! nothing while its extra is off and no mod runs.
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
//! Each hook does nothing while its extra is off and no mod runs.
use super::{Dim, Game, Screen};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
extras::{Corner, Cps, FpsCounter, fov_target},
extras_hud::{self, Corners},
hud::{HudBatch, MINIMAP_SPRITE},
keybinds,
minimap::{self, Layer, Minimap, View},
multiplayer,
reconnect::{Retry, Tracker},
title::Mode,
};
use std::time::Duration;
use winit::{event::MouseButton, keyboard::KeyCode};
use winit::event::MouseButton;
#[derive(Default)]
pub(super) struct ExtrasState {
zoom: Zoom,
fps: FpsCounter,
/// Left and right mouse buttons: held, and their clicks.
mouse: [bool; 2],
@@ -36,34 +34,8 @@ impl Game<'_> {
fn extras_now(&self) -> f64 {
self.started.elapsed().as_secs_f64()
}
fn zoom_key(&self) -> Option<KeyCode> {
let e = &self.settings.extras;
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
}
/// A key press or release. The zoom key zooms only while playing with
/// no text open; the key itself always goes on to the screen and the key
/// binds, as a vanilla key mapping's would.
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if self.zoom_key() != Some(key) {
return;
}
let playing = self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none();
let level = self.settings.extras.zoom_level;
self.extras.zoom.key(pressed, repeat, playing, level);
}
/// Wheel steps while playing; true when zooming took them from the hotbar.
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
}
/// Mouse buttons while playing, for zoom and the keystroke overlay.
/// Mouse buttons while playing, for mods' clicks.
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
let e = &self.settings.extras;
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
self.extras.zoom.key(pressed, false, true, e.zoom_level);
}
let i = match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
@@ -81,9 +53,6 @@ impl Game<'_> {
// Under agent control the window may lack focus without pausing.
let focused = self.focused || self.control.is_some();
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
if !playing || !self.settings.extras.zoom {
self.extras.zoom.release();
}
if !playing {
self.extras.mouse = [false; 2];
}
@@ -159,25 +128,29 @@ impl Game<'_> {
}
Ok(())
}
/// The minimap's view of the player, if it shows.
fn minimap_view(&self) -> Option<View> {
let e = &self.settings.extras;
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
/// in GUI pixels and its view of the player.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
let p = self.player.as_ref()?;
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
shown.then_some(View {
if self.title.is_some() || self.ui.hide_gui {
return None;
}
let m = self.mod_minimap()?;
let view = View {
x: p.position.x,
z: p.position.z,
yaw: self.yaw,
rotate: e.minimap_rotate,
blocks_per_pixel: e.minimap_zoom,
round: e.minimap_round,
})
rotate: m.rotate,
blocks_per_pixel: m.blocks_per_pixel,
round: m.round,
};
Some((m.corner, m.size, view))
}
/// Refresh the minimap's tiles within the frame budget, recompose its
/// image when the view or a tile changed, and queue the upload.
fn update_minimap(&mut self) {
let Some(view) = self.minimap_view() else {
if !self.settings.extras.minimap || self.player.is_none() {
let Some((_, _, view)) = self.minimap_shown() else {
if self.mod_minimap().is_none() || self.player.is_none() {
self.extras.minimap.clear();
self.extras.minimap_dim = None;
}
@@ -225,24 +198,12 @@ impl Game<'_> {
rgba: self.extras.minimap.image.clone(),
});
}
/// The zoom in effect (1 unless the key is held).
fn zoom_factor(&self) -> f32 {
self.extras.zoom.factor(self.scoping())
}
/// The zoom in effect, the extra's or a mod's (`render.set-zoom`),
/// whichever is stronger: (factor, smooth, turning scale).
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
/// scale); none is 1.
fn zoom_now(&self) -> (f32, bool, f64) {
let factor = self.zoom_factor();
let e = &self.settings.extras;
let own = (
factor,
e.zoom_smooth,
turn_scale(factor, e.zoom_sensitivity),
);
match self.mod_zoom() {
Some(z) if z.factor > factor => (z.factor, z.smooth, f64::from(z.turn)),
_ => own,
}
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
(z.factor, z.smooth, f64::from(z.turn))
})
}
/// The field-of-view modifier target, divided by a smooth zoom.
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
@@ -265,16 +226,15 @@ impl Game<'_> {
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
/// by the caller). The top left corner gives way to the debug screen.
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let e = &self.settings.extras;
let mod_panels = self.mods.has_panels();
if !e.minimap && !mod_panels {
let map = self.minimap_shown();
if map.is_none() && !mod_panels {
return;
}
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
let atlas = &self.gui.atlas;
if let Some(view) = self.minimap_view()
if let Some((map_corner, map_size, view)) = map
&& visible(map_corner)
&& !self.extras.minimap.image.is_empty()
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
+73 -15
View File
@@ -71,6 +71,18 @@ impl Rocket {
(self.position, self.velocity)
}
}
/// `damageAmount * (float)Math.sqrt((5.0 - this.distanceTo(target)) / 5.0)`
/// from the rocket at `from` to a target at `to`; `Entity.distanceTo` sums
/// the squared offsets in floats.
fn blast_damage(damage: f32, from: Vec3, to: Vec3) -> f32 {
let (x, y, z) = (
(from.x - to.x) as f32,
(from.y - to.y) as f32,
(from.z - to.z) as f32,
);
let distance = f64::from(x * x + y * y + z * z).sqrt() as f32;
damage * ((5.0 - f64::from(distance)) / 5.0).sqrt() as f32
}
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
// The item's default component: flight 1, no explosions.
stack
@@ -251,21 +263,24 @@ impl Game<'_> {
let at = [r.position.x, r.position.y, r.position.z];
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
// The player it boosts takes the full damage, a guest as well.
if r.attached {
match r.guest {
None => self.hurt_player_by("minecraft:fireworks", damage),
Some(conn) => {
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| {
g.hurt_player_by("minecraft:fireworks", damage);
});
self.lan = Some(host);
}
}
}
}
let from = r.position;
// `attachedToEntity` takes the full damage, a guest as well; every
// other player in range and in sight takes the blast.
let boosted = r.guest.filter(|_| r.attached);
if r.attached && boosted.is_none() {
self.rocket_hurt_player(damage);
} else {
self.rocket_blast_player(from, damage);
}
if let Some(conn) = boosted
&& let Some(mut host) = self.lan.take()
{
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
self.lan = Some(host);
}
// Every other player in the level (LAN guests, the players of a
// server thread), each swapped in as the player.
self.each_guest_hit_except(boosted, |g| g.rocket_blast_player(from, damage));
let hits: Vec<(u32, f32)> = {
let world = self.mob_world_base(&[]);
self.mobs
@@ -285,7 +300,7 @@ impl Game<'_> {
);
world.clear_between(from, to, false)
});
sees.then(|| (m.id, damage * ((5.0 - d2.sqrt()) / 5.0).sqrt() as f32))
sees.then(|| (m.id, blast_damage(damage, from, m.position)))
})
.collect()
};
@@ -307,6 +322,49 @@ impl Game<'_> {
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
}
/// `dealExplosionDamage` on a player that is not the rocket's
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
fn rocket_blast_player(&mut self, from: Vec3, damage: f32) {
let Some(p) = self.player.as_ref() else {
return;
};
let feet = p.position;
let d2 = (feet.x - from.x).powi(2) + (feet.y - from.y).powi(2) + (feet.z - from.z).powi(2);
if d2 > 25.0 {
return;
}
let height = f64::from(p.dimensions.height);
let sees = {
let world = self.mob_world_base(&[]);
(0..2).any(|step| {
let to = Vec3::new(feet.x, feet.y + height * 0.5 * f64::from(step), feet.z);
world.clear_between(from, to, false)
})
};
if sees {
self.rocket_hurt_player(blast_damage(damage, from, feet));
}
}
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
/// wears (`hurtArmor`); creative and spectator players are immune.
fn rocket_hurt_player(&mut self, amount: f32) {
if self.creative || self.spectator() {
return;
}
let hurt = if let Some(p) = self.player.as_mut()
&& !p.health.dead_or_dying()
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
}
}
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
/// lit at the rocket's light probe.
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
+7 -7
View File
@@ -64,14 +64,14 @@ impl Game<'_> {
});
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
fn death_time(&self) -> Option<i32> {
/// The player's `deathTime` while dead or dying: whole ticks counted by
/// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in
/// `Camera.extractRenderState` rises smoothly across frames.
pub(super) fn death_time(&self) -> Option<i32> {
let p = self.player.as_ref()?;
p.health.dead_or_dying().then(|| {
self.ui
.died_at
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
})
p.health
.dead_or_dying()
.then(|| self.ui.death_time.min(i32::MAX as u32) as i32)
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
@@ -370,6 +370,12 @@ impl Game<'_> {
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
if self.agent.hold_server {
// Keep servicing control requests so the fixture can release the barrier.
// Unlike /tick freeze, no player's queued packets are applied here.
self.wait_for_next_server_tick(&mut next, true);
continue;
}
let paused = flags.paused.load(Ordering::Relaxed);
if paused {
// Only the connections run.
+105 -58
View File
@@ -38,6 +38,9 @@ pub(super) struct GuestPlay {
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
/// item a creative player drops from the creative screen, one less each tick.
creative_drops: u32,
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
attack_ticks: u32,
@@ -1086,6 +1089,35 @@ impl Game<'_> {
}
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
S::SetCreativeModeSlot(p) if p.slot < 0 => {
if !host.hub.guests[gi].creative() {
return;
}
let Some(stack) = self.item_from_network(host, &p.item) else {
return;
};
if stack.count
> self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack)
{
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
"Player {} was dropping items too fast in creative mode, ignoring.",
host.hub.guests[gi].name
);
return;
}
play.creative_drops += 20;
self.as_guest(host, conn, |g| g.player_drop(stack));
}
S::SetCreativeModeSlot(p) => {
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
@@ -1160,6 +1192,10 @@ impl Game<'_> {
{
self.container_changed(key);
}
// A vehicle's menu edits the vehicle's own items.
if let Some(Slots::Block(key)) = slots {
self.store_cart_items(key);
}
self.drop_guest_items(host, gi, drops);
}
S::ContainerClose(p) => {
@@ -1799,12 +1835,17 @@ impl Game<'_> {
continue;
}
if self.on_server_trip(conn) {
// LivingEntity.hurt kills at once (die), whether the level
// has loaded around the player or not: its respawn request
// is taken as soon as it comes.
self.guest_die(host, gi, conn);
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
@@ -1883,69 +1924,72 @@ impl Game<'_> {
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_orbs(host, gi);
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
self.guest_die(host, gi, conn);
}
}
/// `ServerPlayer.die` for a guest whose health has run out: the death
/// message, the loot and experience, the death screen.
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
let message = if self.ui.rules.bool("show_death_messages") {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log(
"INFO",
&format!("System chat: {}", text.plain()),
);
} else if self.player.is_some() {
self.system_message(text);
}
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops =
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
let message = if self.ui.rules.bool("show_death_messages") {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
message
} else {
literal("")
None => self.guest_death_message(conn, me),
};
host.hub.send_to(
conn,
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
}
self.spawn_orbs(at, reward);
message
} else {
literal("")
};
host.hub.send_to(
conn,
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
}
self.spawn_orbs(at, reward);
}
}
@@ -2143,6 +2187,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
@@ -291,6 +291,11 @@ impl Game<'_> {
let at = host.hub.guests[gi].position;
self.award_furnace_recipes(pos, at);
}
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
// so the chunk is saved and comparators read the block again.
if changed.iter().any(|&s| s < items.len()) {
self.container_changed(pos);
}
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
host.hub.inventory_clicked(conn, p, &self.world, &changed);
true
+12 -2
View File
@@ -119,7 +119,17 @@ impl Game<'_> {
/// Runs `f` as each guest in play here, for what strikes every player in
/// an area (`Level.getEntities`), not only the window's own.
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
pub(super) fn each_guest_hit(&mut self, f: impl FnMut(&mut Self)) {
self.each_guest_hit_except(None, f);
}
/// [`Self::each_guest_hit`] leaving out the guest `except` (the one a
/// firework rocket is attached to, which it hurts on its own).
pub(super) fn each_guest_hit_except(
&mut self,
except: Option<u32>,
mut f: impl FnMut(&mut Self),
) {
let Some(mut host) = self.lan.take() else {
return;
};
@@ -127,7 +137,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && Some(g.conn) != except)
.map(|g| g.conn)
.collect();
for conn in conns {
@@ -33,7 +33,7 @@ fn recipe_matches(condition: &Value, id: &str) -> bool {
impl Game<'_> {
/// `Level.setBlock` of one boolean property of the block at `pos`.
fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
pub(crate) fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
let registry = self.pack.assets.registry;
let old = self.world.block(pos.0, pos.1, pos.2);
let Some(mut state) = registry.state(old).cloned() else {
+24 -11
View File
@@ -1117,22 +1117,35 @@ impl Game<'_> {
self.swing_arm();
true
}
/// Closing a cart's container copies the items back.
pub(super) fn close_cart_container(&mut self) {
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
/// Closing the cart's container open under `key` copies the items back.
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
if key.1 != CART_KEY_Y {
return;
};
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
}
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
&& c.kind == CartKind::Chest
{
let at = [c.position.x, c.position.y, c.position.z];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
/// that the menu edits is copied back to the vehicle.
pub(super) fn store_cart_items(&mut self, key: (i32, i32, i32)) {
if key.1 != CART_KEY_Y {
return;
}
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&key) else {
return;
};
let items = items.clone();
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
c.items = items;
let at = [c.position.x, c.position.y, c.position.z];
if c.kind == CartKind::Chest {
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
b.items = items;
}
@@ -28,6 +28,9 @@ impl Game<'_> {
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
None
}
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
@@ -38,6 +41,16 @@ pub(super) struct ModZoom {
pub(super) turn: f32,
}
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModMinimap {
pub(super) corner: horizon_client::extras::Corner,
pub(super) size: f32,
pub(super) blocks_per_pixel: f32,
pub(super) rotate: bool,
pub(super) round: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
@@ -147,4 +160,22 @@ impl Game<'_> {
turn: z.turn,
})
}
/// The minimap the first mod showing one asks for.
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
use horizon_client::extras::Corner;
use horizon_mods::RenderCorner;
let m = self.mods.mods.minimap()?;
Some(ModMinimap {
corner: match m.corner {
RenderCorner::TopLeft => Corner::TopLeft,
RenderCorner::TopRight => Corner::TopRight,
RenderCorner::BottomLeft => Corner::BottomLeft,
RenderCorner::BottomRight => Corner::BottomRight,
},
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
}
}
+7 -2
View File
@@ -27,7 +27,9 @@ use {
text::{self, Text},
ui,
},
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
horizon_mods::{
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
};
@@ -270,7 +272,7 @@ impl Game<'_> {
.collect();
let enabled = &self.settings.mods.enabled;
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load(&paths, on, vanilla_command);
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
@@ -412,6 +414,7 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
}
}
/// A game tick.
@@ -522,6 +525,8 @@ impl Game<'_> {
}
continue;
}
// Server mods' lines: this game runs no server mod yet.
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
};
// Not through system_message: a mod must not hear its own lines.
// Written by this client, so the Chat option doesn't hide it.
@@ -82,6 +82,13 @@ mod with_mods {
Ok(()) => {
self.settings.extras.moved.push(m.id.to_owned());
self.settings.mods.enabled.insert(m.id.to_owned());
// The install wrote the key to horizon-mods.txt;
// the settings save below must keep it.
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(&self.settings.extras) {
keys.entry(name.to_owned()).or_insert(key);
}
self.settings.mods.keys.retain(|_, k| !k.is_empty());
self.mods.moved.notices.push(Text::literal(m.notice()));
installed = true;
}
@@ -1,7 +1,6 @@
//! Horizon's own shader packs in the game (docs/shaders.md), for development until the Shader
//! Packs page exists: `HORIZON_SHADER_PACK=<name>` naming one in `shaderpacks/` (a folder with
//! `horizon-shaders.toml`, or a `.hshader`) runs it once it passes `horizon_shader_api::check`.
//! The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs once it
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
//! pack that fails is refused with its problems in the chat while the last good one keeps
//! drawing.
@@ -21,11 +20,22 @@ const RED: u32 = 0xFF5555;
#[derive(Default)]
pub(super) struct NativeShaders {
/// The pack's name in `shaderpacks/`, of either kind (`game/shader_packs.rs`); empty: none.
pub picked: String,
/// The pack's files when it was last read (`fingerprint`).
stamp: Option<u64>,
looked: Option<Instant>,
}
impl NativeShaders {
pub fn picking(name: &str) -> Self {
Self {
picked: name.to_owned(),
..Self::default()
}
}
}
impl Game<'_> {
/// Builds the pack at `path` when its files changed since it was last read.
pub(super) fn tick_native_shader_pack(&mut self, path: &Path) {
@@ -270,6 +270,18 @@ impl Game<'_> {
self.portals.player.set_inside(pos, PLAYER_DELAY);
self.portals.client_inside = true;
}
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
/// passenger), then only the client's `setAsInsidePortal`, which drives
/// the confusion effect (`client_portal_effect`).
pub(super) fn client_inside_nether_portal(&mut self) {
let alive = self
.player
.as_ref()
.is_some_and(|p| !p.health.dead_or_dying());
if alive && self.riding.is_none() {
self.portals.client_inside = true;
}
}
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
/// player then). Players wait `players_nether_portal_default_delay`
/// ticks, or the creative delay with invulnerable abilities.
+15
View File
@@ -78,6 +78,10 @@ impl Game<'_> {
.unwrap_or_default()
.join("options.txt")
}
/// `shaderpacks/` beside `options.txt`.
pub(super) fn shader_packs_dir() -> std::path::PathBuf {
Self::options_path().with_file_name(horizon_client::shader_pack_list::DIR)
}
pub(super) fn gui_size(&self) -> (f32, f32) {
let size = self
.renderer
@@ -230,6 +234,17 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ListShaderPacks => {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
}
// Saved; the next frame's `shader_pack_inputs` loads the pick.
Event::ShaderPack => self.settings.save(&Self::options_path()),
Event::OpenShaderPackFolder => {
let dir = Self::shader_packs_dir();
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
+133 -199
View File
@@ -6,11 +6,12 @@
//! clicks). The player still moves with the local physics, as the vanilla
//! client predicts its own movement; the server stays authoritative for
//! everything else.
use super::remote_chunks::{ChunkDecoder, ChunkFormat, Next};
use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
chunk::{Chunk, SECTIONS},
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
hud::{HudBatch, rgba},
@@ -25,11 +26,7 @@ use horizon_client::{
tab_list::SPECTATOR,
world::World,
};
use horizon_core::{
geometry::Vec3,
paletted::{PalettedContainer, Strategy},
random::RandomSource,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{ITEM_ENTITY_ITEM, Value},
item::{Hashed, Stack},
@@ -64,9 +61,6 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
remote_text(tag).resolve_legacy()
}
/// A section's sky and block light layers from the wire.
type LightLayers = (Option<Vec<u8>>, Option<Vec<u8>>);
/// Streaming needs a generator; a server world never generates.
struct NoTerrain;
impl ChunkGenerator for NoTerrain {
@@ -104,6 +98,14 @@ fn place_interpolated(m: &mut Mob, at: PosRot) {
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
}
/// What the network thread delivered, in order: an event, or a chunk packet
/// handed to the decoder (with the format it decodes with), whose column
/// comes back from `ChunkDecoder::next`.
enum Received {
Event(Event),
Chunk(Arc<ChunkFormat>),
}
pub(super) struct Remote {
pub session: Session,
pub brand: String,
@@ -114,8 +116,11 @@ pub(super) struct Remote {
status: String,
pub(super) entity_id: i32,
dimension: DimensionType,
biome_map: Vec<u16>,
biome_bits: u8,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
@@ -200,7 +205,7 @@ pub(super) struct Remote {
chunk_center: (i32, i32),
/// Received events a frame left for the next one, in order, and when
/// the oldest of them first waited (`poll_remote`).
backlog: VecDeque<Event>,
backlog: VecDeque<Received>,
backlog_since: Option<Instant>,
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
@@ -210,11 +215,12 @@ pub(super) struct Remote {
/// The time a frame spends on chunk packets before it leaves the rest of
/// the received events for the next frame. A batch of 25 to 60 columns
/// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every
/// time the player crossed a chunk border at 32 chunks.
/// time the player crossed a chunk border at 32 chunks; they decode on the
/// chunk decoder's thread now, and the frame only inserts them.
const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
/// Events that have waited this long are all handled in the next frame,
/// whatever their cost: the packets after a slow machine's chunks never
/// wait more than a tick.
/// whatever inserting their columns costs, up to a column still decoding:
/// the packets after a slow machine's chunks don't wait on its frames.
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
/// The open menu name of a server's horse inventory, then its chest's
/// columns (the packet names no `MenuType`).
@@ -420,8 +426,12 @@ impl Game<'_> {
status: "Connecting to the server...".into(),
entity_id: 0,
dimension: DimensionType::vanilla("minecraft:overworld"),
biome_map: Vec::new(),
biome_bits: 7,
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
7,
)),
decoder: ChunkDecoder::new(),
view_distance: self.settings.render_distance,
sequence: 0,
position_sender: Default::default(),
@@ -741,25 +751,63 @@ impl Game<'_> {
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`.
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
events.extend(r.session.poll());
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
while let Some(event) = events.pop_front() {
let chunk =
matches!(&event, Event::Packet(p) if matches!(**p, C::LevelChunkWithLight(_)));
while let Some(item) = events.pop_front() {
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(event);
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
@@ -915,8 +963,8 @@ impl Game<'_> {
})
.unwrap_or_default();
if let Some(r) = self.remote.as_mut() {
r.biome_bits = ceil_log2(biomes.len());
r.biome_map = biomes;
let bits = ceil_log2(biomes.len());
r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits));
r.registries = registries;
}
}
@@ -927,6 +975,7 @@ impl Game<'_> {
};
let dimension = r.registries.dimension(spawn.dimension_type);
let same = r.dimension.name == dimension.name && keep_world;
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.loaded_sent = false;
@@ -989,145 +1038,10 @@ impl Game<'_> {
self.apply_settings();
}
/// LevelChunkSection data into the client's fixed -64..320 column.
fn remote_chunk(&self, p: &play::LevelChunkWithLight) -> std::result::Result<Chunk, String> {
let r = self.remote.as_ref().ok_or("no connection")?;
let sections = p
.chunk
.sections(r.dimension.sections(), 16, r.biome_bits)
.map_err(|e| e.to_string())?;
let mut chunk = Chunk::empty(p.x, p.z);
let offset = (r.dimension.min_y - MIN_Y).div_euclid(16);
// Biome table index per quart cell of the dimension's own sections.
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
for (i, s) in sections.iter().enumerate() {
let target = offset + i as i32;
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = u32::from(
r.biome_map
.get(s.biomes.get(j) as usize)
.copied()
.unwrap_or(0),
);
}
biome_cells.push(cells);
if !(0..SECTIONS as i32).contains(&target) {
continue;
}
// A global section is as wide as the server's registry: 16 bits
// from a vanilla server, wider from a Horizon host with the same
// content mods. Read it at that width, then widen it to ours.
let ours = block_strategy();
let strategy = if ours.wire_has_palette(s.states.bits) {
ours
} else {
Strategy::blocks(s.states.bits)
};
let palette = s.states.palette.iter().map(|&p| p as u32).collect();
let words = s.states.data.iter().map(|&w| w as u64).collect();
let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
.map_err(|e| e.to_string())?;
if strategy.global_bits != ours.global_bits {
let mut values = [0u32; 4096];
blocks.unpack(&mut values);
// Ids past our registry (a host with mods we lack) read as air.
let limit = 1u64 << ours.global_bits;
for v in &mut values {
if u64::from(*v) >= limit {
*v = 0;
}
}
blocks = PalettedContainer::from_values(ours, &values);
}
let mut section = Section {
blocks,
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
light: Light::Uniform(0xF0),
};
section.compact();
chunk.sections[target as usize] = Arc::new(section);
}
// Sections outside the dimension's range take its nearest biomes
// (the column's top or bottom cell layer repeated).
if !biome_cells.is_empty() {
let last = biome_cells.len() - 1;
for si in 0..SECTIONS as i32 {
let i = si - offset;
if (0..sections.len() as i32).contains(&i) {
continue;
}
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = biome_cells[edge][layer * 16 + j % 16];
}
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
}
}
// ClientPacketListener.applyLightData: the column keeps the light
// the server sent; nothing is relit.
let (layers, above) = self.wire_light(&p.light);
let sky = r.dimension.has_skylight;
horizon_client::light::light_from_layers(&mut chunk, &layers, above, sky);
Ok(chunk)
}
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
/// light packet, by section of the client's column, and the sky light
/// above the column's top section (from the light section just above
/// it, else open sky).
fn wire_light(
&self,
light: &horizon_protocol::chunk::LightData,
) -> (Vec<LightLayers>, [u8; 256]) {
let mut layers: Vec<LightLayers> = vec![(None, None); SECTIONS];
let Some(r) = self.remote.as_ref() else {
return (layers, [0; 256]);
};
let sky = r.dimension.has_skylight;
let mut above = [if sky { 15 } else { 0 }; 256];
// Light section b is the dimension's section b - 1.
let first = (r.dimension.min_y - MIN_Y).div_euclid(16) - 1;
for (b, layer) in light
.layers(r.dimension.sections() + 2)
.into_iter()
.enumerate()
{
let si = first + b as i32;
if (0..SECTIONS as i32).contains(&si) {
layers[si as usize] = layer;
} else if si == SECTIONS as i32
&& sky
&& let Some(l) = &layer.0
{
for (i, a) in above.iter_mut().enumerate() {
*a = (l[i >> 1] >> ((i & 1) * 4)) & 15;
}
}
}
(layers, above)
}
/// `handleLightUpdatePacket`: the sections it names take its light.
fn remote_light_update(&mut self, p: &play::LightUpdate) {
let Some(sky) = self.remote.as_ref().map(|r| r.dimension.has_skylight) else {
return;
};
if !self.world.chunks.contains_key(&(p.x, p.z)) {
return;
}
let (layers, _) = self.wire_light(&p.light);
let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else {
return;
};
let changed =
horizon_client::light::update_light_layers(Arc::make_mut(chunk), &layers, sky);
Streaming::light_updated(&mut self.world, p.x, p.z, &changed);
}
/// `ClientChunkCache.Storage.inRange`: within the storage around the
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
self.remote.as_ref().is_none_or(|r| {
let range = r.view_distance.max(2) + 3;
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
@@ -1135,29 +1049,33 @@ impl Game<'_> {
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: Vec<(i32, i32)> = self
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
for (x, z) in gone {
if gone.is_empty() {
return;
}
for &(x, z) in &gone {
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
for sy in 0..SECTIONS as i32 {
renderer.remove((x, sy, z));
}
}
}
// One pass for all of them.
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
/// changes nothing.
fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
pub(super) fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
let key = (pos[0], pos[1], pos[2]);
let Some(name) = self
.remote
@@ -1248,7 +1166,7 @@ impl Game<'_> {
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
pub(super) fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
let name = self.res.facts().get(state).name.clone();
match BlockEntity::for_block(&name) {
Some(entity) => {
@@ -1704,36 +1622,7 @@ impl Game<'_> {
})
.clamp(-90.0, 90.0);
}
// ClientChunkCache.replaceWithPacketData: a column outside the
// storage around the view centre is ignored.
C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => {
log::warn!(
"Ignoring chunk since it's not in the view range: {}, {}",
p.x,
p.z
);
}
C::LevelChunkWithLight(p) => match self.remote_chunk(&p) {
Ok(chunk) => {
let (x, z) = (p.x, p.z);
self.streaming.insert_remote(&mut self.world, chunk);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
for info in &p.chunk.block_entities {
let pos = (
x * 16 + i32::from(info.packed_xz as u8 >> 4),
i32::from(info.y),
z * 16 + i32::from(info.packed_xz as u8 & 15),
);
let state = self.world.block(pos.0, pos.1, pos.2);
self.sync_block_entity(pos, state);
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
}
}
Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z),
},
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
@@ -2234,6 +2123,7 @@ impl Game<'_> {
self.apply_remote_health();
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
}
}
@@ -2250,6 +2140,7 @@ impl Game<'_> {
// screen, whatever health was last sent.
if me && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
}
}
@@ -3288,6 +3179,11 @@ impl Game<'_> {
);
let was_flying = player.flying;
self.sync_player_body();
// LivingEntity.tickDeath: the local player counts its deathTime once
// per client tick while dead (the camera's death roll and zoom).
if self.screen == Screen::Dead {
self.ui.death_time = self.ui.death_time.saturating_add(1);
}
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3343,6 +3239,12 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
_ => {}
}
}
@@ -3610,7 +3512,7 @@ impl Game<'_> {
// ticks), and only the server removes it.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(world, facts, collision, false);
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
for (item, ticks, at, eye, pitch, yaw) in using {
@@ -4060,6 +3962,10 @@ impl Game<'_> {
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
} else if let Some(r) = self.remote.as_mut() {
// BlockItem.place plays its sound only once the block is set: the
// server's resend of the target it refused is no placement.
r.placing = None;
}
if reacts || placed.is_some() {
self.with_hand(hand, Game::swing_arm);
@@ -4828,6 +4734,34 @@ impl Game<'_> {
carried,
}));
}
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
/// -1, and the hand swings as `Player.drop` predicts.
pub(super) fn remote_creative_drop(&mut self, right: bool) {
let one = right && !self.creative_inventory_open();
let Some(carried) = self.carried.as_mut() else {
return;
};
let thrown = if one && carried.count > 1 {
carried.count -= 1;
ItemStack {
count: 1,
..carried.clone()
}
} else {
self.carried.take().expect("carried")
};
if !self.creative {
return;
}
self.swing_arm();
let item = self.to_network(&Some(thrown));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: -1,
item,
}));
}
pub(super) fn remote_close_container(&mut self) {
let id = self
.remote
@@ -0,0 +1,355 @@
//! A server's chunk columns (`ClientboundLevelChunkWithLightPacket`): the
//! sections and light of each packet become a client column on a decoder
//! thread as soon as the packet arrives, so the frame that handles it in
//! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`).
use super::Game;
use horizon_client::{
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
streaming::Streaming,
};
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_protocol::{
chunk::{LightData, SectionLight},
packets::play::{self, Clientbound as C},
registry::DimensionType,
};
use std::sync::{
Arc,
mpsc::{self, TryRecvError},
};
/// What turning a chunk packet into a client column depends on: the
/// dimension's height and sky, and the server's biome registry mapped to
/// ours. The connection replaces it when either changes, so a column decoded
/// with an older one is decoded again.
#[derive(Default)]
pub(super) struct ChunkFormat {
min_y: i32,
sections: usize,
has_skylight: bool,
biome_map: Vec<u16>,
biome_bits: u8,
}
impl ChunkFormat {
pub(super) fn new(dimension: &DimensionType, biome_map: Vec<u16>, biome_bits: u8) -> Self {
Self {
min_y: dimension.min_y,
sections: dimension.sections(),
has_skylight: dimension.has_skylight,
biome_map,
biome_bits,
}
}
pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self {
Self::new(dimension, self.biome_map.clone(), self.biome_bits)
}
/// LevelChunkSection data into the client's fixed -64..320 column.
fn column(&self, p: &play::LevelChunkWithLight) -> Result<Chunk, String> {
let sections = p
.chunk
.sections(self.sections, 16, self.biome_bits)
.map_err(|e| e.to_string())?;
let mut chunk = Chunk::empty(p.x, p.z);
let offset = (self.min_y - MIN_Y).div_euclid(16);
// Biome table index per quart cell of the dimension's own sections.
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
for (i, s) in sections.iter().enumerate() {
let target = offset + i as i32;
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = u32::from(
self.biome_map
.get(s.biomes.get(j) as usize)
.copied()
.unwrap_or(0),
);
}
biome_cells.push(cells);
if !(0..SECTIONS as i32).contains(&target) {
continue;
}
// A global section is as wide as the server's registry: 16 bits
// from a vanilla server, wider from a Horizon host with the same
// content mods. Read it at that width, then widen it to ours.
let ours = block_strategy();
let strategy = if ours.wire_has_palette(s.states.bits) {
ours
} else {
Strategy::blocks(s.states.bits)
};
let palette = s.states.palette.iter().map(|&p| p as u32).collect();
let words = s.states.data.iter().map(|&w| w as u64).collect();
let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
.map_err(|e| e.to_string())?;
if strategy.global_bits != ours.global_bits {
let mut values = [0u32; 4096];
blocks.unpack(&mut values);
// Ids past our registry (a host with mods we lack) read as air.
let limit = 1u64 << ours.global_bits;
for v in &mut values {
if u64::from(*v) >= limit {
*v = 0;
}
}
blocks = PalettedContainer::from_values(ours, &values);
}
let mut section = Section {
blocks,
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
light: Light::Uniform(0xF0),
};
section.compact();
chunk.sections[target as usize] = Arc::new(section);
}
// Sections outside the dimension's range take its nearest biomes
// (the column's top or bottom cell layer repeated).
if !biome_cells.is_empty() {
let last = biome_cells.len() - 1;
for si in 0..SECTIONS as i32 {
let i = si - offset;
if (0..sections.len() as i32).contains(&i) {
continue;
}
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = biome_cells[edge][layer * 16 + j % 16];
}
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
}
}
// ClientPacketListener.applyLightData: the column keeps the light
// the server sent; nothing is relit.
let (layers, above) = self.light(&p.light);
horizon_client::light::light_from_layers(&mut chunk, &layers, above, self.has_skylight);
Ok(chunk)
}
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
/// light packet, by section of the client's column, and the sky light
/// above the column's top section (from the light section just above
/// it, else open sky).
pub(super) fn light(&self, light: &LightData) -> (Vec<SectionLight>, [u8; 256]) {
let mut layers: Vec<SectionLight> = vec![(None, None); SECTIONS];
let sky = self.has_skylight;
let mut above = [if sky { 15 } else { 0 }; 256];
// Light section b is the dimension's section b - 1.
let first = (self.min_y - MIN_Y).div_euclid(16) - 1;
for (b, layer) in light.layers(self.sections + 2).into_iter().enumerate() {
let si = first + b as i32;
if (0..SECTIONS as i32).contains(&si) {
layers[si as usize] = layer;
} else if si == SECTIONS as i32
&& sky
&& let Some(l) = &layer.0
{
for (i, a) in above.iter_mut().enumerate() {
*a = (l[i >> 1] >> ((i & 1) * 4)) & 15;
}
}
}
(layers, above)
}
}
type Decoded = (Box<C>, Result<Chunk, String>);
/// One thread that decodes chunk packets in the order they arrived; its
/// results come back in that order, one per `submit`.
pub(super) struct ChunkDecoder {
jobs: mpsc::Sender<(Arc<ChunkFormat>, Box<C>)>,
done: mpsc::Receiver<Decoded>,
}
impl ChunkDecoder {
pub(super) fn new() -> Self {
let (jobs, todo) = mpsc::channel::<(Arc<ChunkFormat>, Box<C>)>();
let (finished, done) = mpsc::channel();
// The thread ends with the connection, when `jobs` is dropped.
let spawned = std::thread::Builder::new()
.name("Chunk decoder".into())
.spawn(move || {
// A frame waits for its columns, as it would for the render
// thread's own work.
horizon_threads::favour_current();
for (format, packet) in todo {
let column = match &*packet {
C::LevelChunkWithLight(p) => format.column(p),
_ => Err("not a chunk packet".into()),
};
if finished.send((packet, column)).is_err() {
break;
}
}
});
if let Err(e) = spawned {
log::warn!("Horizon: no chunk decoder thread ({e}); chunks decode in the frame");
}
Self { jobs, done }
}
/// Start decoding a chunk packet with the format it arrived under.
/// Without a decoder thread the packet comes back undecoded.
pub(super) fn submit(&self, format: Arc<ChunkFormat>, packet: Box<C>) -> Option<Box<C>> {
self.jobs.send((format, packet)).err().map(|e| (e.0).1)
}
/// The oldest submitted packet and its column, unless it is still
/// decoding: a frame never waits for the decoder.
pub(super) fn next(&self) -> Next {
match self.done.try_recv() {
Ok(decoded) => Next::Ready(decoded),
Err(TryRecvError::Empty) => Next::Decoding,
// The decoder thread panicked: its columns are gone.
Err(TryRecvError::Disconnected) => Next::Lost,
}
}
}
pub(super) enum Next {
Ready(Decoded),
Decoding,
Lost,
}
impl Game<'_> {
/// `handleLevelChunkWithLight`: the column replaces any loaded one, and
/// its block entities load their update tags. `decoded` is the column the
/// decoder made with `format`; another format since means decode again.
pub(super) fn remote_chunk(
&mut self,
p: &play::LevelChunkWithLight,
decoded: Option<(&Arc<ChunkFormat>, Result<Chunk, String>)>,
) {
// ClientChunkCache.replaceWithPacketData: a column outside the
// storage around the view centre is ignored.
if !self.remote_chunk_in_range(p.x, p.z) {
log::warn!(
"Ignoring chunk since it's not in the view range: {}, {}",
p.x,
p.z
);
return;
}
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
return;
};
let column = match decoded {
Some((used, column)) if Arc::ptr_eq(used, &format) => column,
_ => format.column(p),
};
let chunk = match column {
Ok(chunk) => chunk,
Err(e) => {
log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z);
return;
}
};
let (x, z) = (p.x, p.z);
self.streaming.insert_remote(&mut self.world, chunk);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
for info in &p.chunk.block_entities {
let pos = (
x * 16 + i32::from(info.packed_xz as u8 >> 4),
i32::from(info.y),
z * 16 + i32::from(info.packed_xz as u8 & 15),
);
let state = self.world.block(pos.0, pos.1, pos.2);
self.sync_block_entity(pos, state);
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
}
}
/// `handleLightUpdatePacket`: the sections it names take its light.
pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) {
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
return;
};
if !self.world.chunks.contains_key(&(p.x, p.z)) {
return;
}
let (layers, _) = format.light(&p.light);
let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else {
return;
};
let changed = horizon_client::light::update_light_layers(
Arc::make_mut(chunk),
&layers,
format.has_skylight,
);
Streaming::light_updated(&mut self.world, p.x, p.z, &changed);
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::chunk::{ChunkData, Paletted, Section as Wire, Strategy as Wiring};
/// A column of `state` with biome 1 everywhere, unlit.
fn packet(x: i32, state: i32) -> Box<C> {
let section = Wire {
non_empty: 4096,
fluids: 0,
states: Paletted::from_values(&[state; 4096], Wiring::Blocks, 16),
biomes: Paletted::from_values(&[1; 64], Wiring::Biomes, 2),
};
let none = LightData {
sky_mask: Vec::new(),
block_mask: Vec::new(),
empty_sky_mask: Vec::new(),
empty_block_mask: Vec::new(),
sky: Vec::new(),
block: Vec::new(),
};
Box::new(C::LevelChunkWithLight(play::LevelChunkWithLight {
x,
z: -3,
chunk: ChunkData {
heightmaps: Vec::new(),
buffer: ChunkData::from_sections(&vec![section; 24]),
block_entities: Vec::new(),
},
light: none,
}))
}
/// Columns come back one per packet, in the order the packets went in,
/// as the frame would have decoded them itself.
#[test]
fn the_decoder_returns_columns_in_order() {
let overworld = DimensionType::vanilla("minecraft:overworld");
let format = Arc::new(ChunkFormat::new(&overworld, vec![0, 7, 9], 2));
let decoder = ChunkDecoder::new();
for x in 0..20 {
assert!(decoder.submit(format.clone(), packet(x, 1 + x)).is_none());
}
for x in 0..20 {
let (packet, column) = loop {
match decoder.next() {
Next::Ready(decoded) => break decoded,
Next::Decoding => std::thread::yield_now(),
Next::Lost => panic!("the decoder stopped"),
}
};
let C::LevelChunkWithLight(p) = *packet else {
panic!("not a chunk packet");
};
let (column, direct) = (column.unwrap(), format.column(&p).unwrap());
assert_eq!((column.x, column.z), (x, -3));
for (cx, y, cz) in [(0, -64, 0), (5, 70, 9), (15, 319, 15)] {
assert_eq!(column.get(cx, y, cz), 1 + x as u32);
assert_eq!(column.get(cx, y, cz), direct.get(cx, y, cz));
// Wire biome 1 is our biome 7.
assert_eq!(column.biome(cx, y, cz), 7);
}
}
assert!(matches!(decoder.next(), Next::Decoding));
}
}
@@ -353,7 +353,8 @@ impl Game<'_> {
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
}
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player)}));
}
for (kind, p, _) in self.potions.thrown_entities() {
out.push(json!({"kind": kind, "position": at(p)}));
+21 -11
View File
@@ -1,7 +1,8 @@
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world; Horizon's
//! own packs go to `game/native_shaders.rs`. The
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
//! Shader packs: the one picked on Options > Horizon Settings > Shader Packs, or
//! `HORIZON_SHADER_PACK=<name>` (development; it wins over the pick), runs `shaderpacks/<name>`
//! (a folder or a zip beside `options.txt`) over the world; Horizon's own packs go to
//! `game/native_shaders.rs`. The programs of the current dimension's folder are loaded when the
//! dimension changes (spec 1.2).
use horizon_client::camera::CameraFluid;
use horizon_client::gfx::pack_uniforms::PackInputs;
@@ -34,25 +35,34 @@ impl Game<'_> {
/// Loads the pack for the current dimension when it changed, and returns this frame's
/// inputs to the pack's uniforms.
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
let picked = dev.as_deref().unwrap_or(&self.settings.horizon.shader_pack);
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
self.pack_ids = None;
if let Some(renderer) = &mut self.renderer {
renderer.drop_shader_packs();
}
}
if picked.is_empty() {
return PackInputs::default();
};
}
let name = picked.to_owned();
let dimension = match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
};
let path = std::env::current_dir()
.unwrap_or_default()
.join("shaderpacks")
.join(&name);
let path = Self::shader_packs_dir().join(&name);
if horizon_shader_api::package::is_pack(&path) {
// One of Horizon's own packs (game/native_shaders.rs).
self.tick_native_shader_pack(&path);
} else if let Some(renderer) = &mut self.renderer
&& renderer.shader_pack_dimension() != Some(dimension)
{
let name = name.to_string_lossy();
let name = name.as_str();
let started = std::time::Instant::now();
match Pack::open(&path) {
Ok(pack) => {
@@ -366,6 +366,16 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::ListShaderPacks => {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Game::shader_packs_dir());
}
Event::ShaderPack => self.save_menu_settings(),
Event::OpenShaderPackFolder => {
let dir = Game::shader_packs_dir();
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::Credits => {
if let Some(gui) = &self.gui {
let name = self.options.as_ref().map_or_else(
+154 -41
View File
@@ -67,7 +67,9 @@ mod end_sky;
mod flat;
pub mod lightning;
mod lod;
mod native_pack;
mod native_post;
mod native_sky;
mod occlusion;
mod pack_atlas;
pub mod pack_draw;
@@ -86,6 +88,7 @@ mod stars;
mod timing;
pub mod translucency;
pub(crate) mod upload;
mod wgsl_prefix;
mod world_post;
/// A column whose every section has its mesh (`Renderer::lod_detail_columns`).
@@ -501,7 +504,9 @@ pub struct Renderer {
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
native_post: Option<native_post::NativePost>,
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
pack_entity_rest: Vec<Vertex>,
/// Set 1 of the cloud pipeline, for a shader pack's `gbuffers_clouds`.
@@ -2412,7 +2417,8 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
pack_post: None,
native_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
pack_entity_rest: Vec::new(),
cloud_bindings,
pack_weather: None,
@@ -2921,35 +2927,40 @@ impl Renderer {
});
self.pack_post = Some((dimension, post));
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
self.native_post = None;
self.native = None;
}
report
}
/// Runs the post passes of one of Horizon's own shader packs over the world from now on
/// (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
/// passes' labels; on an error the passes that ran keep running.
/// Drops the running shader pack, of either kind.
pub fn drop_shader_packs(&mut self) {
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
}
/// Runs the sky and post stages of one of Horizon's own shader packs from now on (`None`:
/// no pack), with its options' values; replaces a `shaders/` pack. Returns the stages'
/// labels; on an error the pack that ran keeps running.
pub fn set_native_shader_pack(
&mut self,
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
) -> std::result::Result<Vec<String>, String> {
let stages = pack.map(|(report, constants)| {
let post: Vec<_> = report
.stages
.into_iter()
.filter(|s| s.kind == horizon_shader_api::StageKind::Post)
.collect();
(post, constants)
});
let Some((post, constants)) = stages.filter(|(post, _)| !post.is_empty()) else {
self.native_post = None;
let Some((report, constants)) = pack else {
self.native = None;
return Ok(Vec::new());
};
let format = self.config.format.remove_srgb_suffix();
let post = native_post::NativePost::new(&self.device, post, &constants, format)?;
let labels = post.labels().into_iter().map(str::to_owned).collect();
self.pack_post = None;
self.pack_atlas = None;
self.native_post = Some(post);
let (pack, labels) = native_pack::NativePack::new(
&self.device,
&self.terrain_layout,
report,
&constants,
format,
)?;
self.native = pack.draws().then_some(pack);
if self.native.is_some() {
self.pack_post = None;
self.pack_atlas = None;
}
Ok(labels)
}
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
@@ -2992,6 +3003,34 @@ impl Renderer {
.terrain_inputs(self.pack_terrain_flat),
)
}
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
/// with `HORIZON_SHADER_PACK_TIMING`.
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_pack::NativePack>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(native)) => Some(&mut native.timing),
_ => None,
}
}
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
/// they are timed (`set_pass_timing`).
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
@@ -4015,7 +4054,15 @@ impl Renderer {
// The world's targets: the screen, or the scaled target of `post`.
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
// One of Horizon's own packs reads the same world target.
let native_post = !frame.title && self.native_post.is_some();
let native_post = !frame.title && self.native.as_ref().is_some_and(|n| n.post.is_some());
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native)
&& timer.is_some() != self.pass_timing
{
*timer = self
.pass_timing
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
let pack_reads_world = pack_post || native_post;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
@@ -4070,24 +4117,79 @@ impl Renderer {
cull.bind_depth(&self.device, world_depth);
}
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
wgpu::Color::BLACK
} else {
wgpu::Color {
r: f64::from(fog_color[0]),
g: f64::from(fog_color[1]),
b: f64::from(fog_color[2]),
a: 1.0,
}
};
let sky_visible = sky_open(frame);
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
// pass of its own, so that it has a time of its own.
let mut native_sky_pass = false;
if !frame.title
&& let Some(native) = &mut self.native
{
let size = self
.world_post
.size()
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
native.frame.write(&self.queue, frame, size);
if let Some(t) = &mut native.timing {
t.begin(&self.device, &self.queue);
native_sky_pass =
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
&& let Some(sky) = &native.sky
{
if let Some(t) = &mut native.timing {
let format = self.config.format.remove_srgb_suffix();
t.mark(&self.device, &mut encoder, (world_view, format));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&sky.label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: world_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: world_depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
timestamp_writes: native.timing.as_mut().and_then(|t| t.writes(&sky.label)),
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
sky.draw(&mut pass, &native.frame);
}
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: world_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(
if frame.title || (frame.end_sky && !frame.underwater) {
wgpu::Color::BLACK
} else {
wgpu::Color {
r: f64::from(fog_color[0]),
g: f64::from(fog_color[1]),
b: f64::from(fog_color[2]),
a: 1.0,
}
},
),
load: if native_sky_pass {
wgpu::LoadOp::Load
} else {
wgpu::LoadOp::Clear(clear)
},
store: wgpu::StoreOp::Store,
},
})];
@@ -4110,11 +4212,19 @@ impl Renderer {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
let sky_visible = sky_open(frame);
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
if !frame.title && frame.skybox && !pack_draws_sky {
pass.set_pipeline(&self.sky);
pass.draw(0..3, 0..1);
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
match self
.native
.as_ref()
.and_then(|n| Some((n.sky.as_ref()?, &n.frame)))
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky);
pass.draw(0..3, 0..1);
}
}
}
if frame.title {
self.panorama.draw(&mut pass);
@@ -4466,17 +4576,17 @@ impl Renderer {
self.pack_post = Some((dimension, Some(post)));
} else if let Some((color, depth)) = &scaled
&& native_post
&& let Some(post) = &mut self.native_post
&& let Some(native) = &mut self.native
&& let Some(post) = &mut native.post
{
let size = self.world_post.size().unwrap_or([1, 1]);
post.run(
&self.device,
&self.queue,
&mut encoder,
(color, depth),
&view_target,
frame,
size,
native.timing.as_mut(),
);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
@@ -4649,6 +4759,9 @@ impl Renderer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
@@ -0,0 +1,60 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use super::native_post::{NativeFrame, NativePost};
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
pub struct NativePack {
pub frame: NativeFrame,
pub sky: Option<NativeSky>,
pub post: Option<NativePost>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto targets of `format`; `globals` is the terrain's group 0, which the sky reads.
/// Also returns its stages' labels, in the order they draw. Errors name the stage wgpu
/// refused.
pub fn new(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
report: Report,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
) -> Result<(Self, Vec<String>), String> {
let frame = NativeFrame::new(device);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
.partition(|s| s.kind == StageKind::Post);
let sky = others
.iter()
.find(|s| s.kind == StageKind::Sky)
.map(|stage| NativeSky::new(device, globals, &frame, stage, constants, format))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
frame,
sky,
post,
timing: None,
};
Ok((pack, labels))
}
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.post.is_some()
}
}
+107 -62
View File
@@ -3,7 +3,8 @@
//! the pack's options as pipeline constants. A pass reads bind group 3: the frame, the world's
//! colour and depth, the previous pass's output and two samplers. Passes before the last write
//! half-float targets of the world's size, so values over 1 carry to the next; the last draws
//! onto the screen.
//! onto the screen. Every stage of a pack reads the same `frame`, written once a frame
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
@@ -25,10 +26,89 @@ pub struct NativePost {
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the stages that read only the frame (the sky).
pub layout: wgpu::BindGroupLayout,
pub group: wgpu::BindGroup,
start: std::time::Instant,
frames: u32,
}
/// The uniform `frame` at binding 0, read by `visibility`.
fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Self {
buffer,
layout,
group,
start: std::time::Instant::now(),
frames: 0,
}
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let inputs = frame_inputs(frame, size, seconds, self.frames);
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
pub fn present_constants<'a>(
module: &naga::Module,
constants: &'a [(String, f64)],
) -> Vec<(&'a str, f64)> {
constants
.iter()
.filter(|(id, _)| {
module
.overrides
.iter()
.any(|(_, o)| o.name.as_ref() == Some(id))
})
.map(|(id, v)| (id.as_str(), *v))
.collect()
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -59,21 +139,13 @@ impl NativePost {
stages: Vec<CheckedStage>,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let float = wgpu::TextureSampleType::Float { filterable: true };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
},
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
@@ -91,17 +163,7 @@ impl NativePost {
let count = stages.len();
let mut passes = Vec::with_capacity(count);
for (i, stage) in stages.into_iter().enumerate() {
let present: Vec<(&str, f64)> = constants
.iter()
.filter(|(id, _)| {
stage
.module
.overrides
.iter()
.any(|(_, o)| o.name.as_ref() == Some(id))
})
.map(|(id, v)| (id.as_str(), *v))
.collect();
let present = present_constants(&stage.module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -141,12 +203,6 @@ impl NativePost {
}
passes.push((stage.label, pipeline));
}
let frame = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
@@ -158,13 +214,12 @@ impl NativePost {
Ok(Self {
passes,
layout,
frame,
frame: frame.buffer.clone(),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
groups: None,
start: std::time::Instant::now(),
frames: 0,
format,
})
}
@@ -258,35 +313,20 @@ impl NativePost {
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
/// `screen`.
/// `screen`, timed by `timing` when it times this frame.
pub fn run(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let inputs = frame_inputs(frame, size, seconds, self.frames);
self.frames = self.frames.wrapping_add(1);
self.draw(device, queue, encoder, world, screen, &inputs, size);
}
fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
inputs: &FrameInputs,
size: [u32; 2],
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size);
queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(inputs));
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
return;
};
@@ -307,6 +347,7 @@ impl NativePost {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
@@ -426,8 +467,20 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let mut post =
NativePost::new(&device, report.stages, &[("gain".into(), 1.0)], format).unwrap();
let frame = NativeFrame::new(&device);
queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&FrameInputs::default()),
);
let mut post = NativePost::new(
&device,
report.stages,
&[("gain".into(), 1.0)],
format,
&frame,
)
.unwrap();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -467,15 +520,7 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
post.draw(
&device,
&queue,
&mut encoder,
(&color, &depth),
&view,
&FrameInputs::default(),
[4, 4],
);
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
+379
View File
@@ -0,0 +1,379 @@
//! The sky stage of Horizon's own shader packs (docs/shaders.md): the pack's `sky` function,
//! called for each pixel of the sky disc with vanilla's colour along its view ray. The
//! pipeline's shader is the pack's stage (`CheckedStage::source`) followed by a copy of the
//! renderer's sky (terrain.wgsl's `Globals`, `linear_fog` and sky section) whose names all
//! take a `horizon_sky_` prefix, so none can meet a pack's (the check refuses `horizon_`
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_sky_";
/// The fragment entry that calls the pack: vanilla's colour in, an opaque colour out.
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
}
";
pub struct NativeSky {
pipeline: wgpu::RenderPipeline,
pub label: String,
}
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
fn vanilla_sky() -> String {
let text = [
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
]
.concat();
prefixed(&text, PREFIX)
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
)
}
/// A sky pipeline over `module` whose fragment entry is `fragment`.
fn pipeline(
device: &wgpu::Device,
layout: &wgpu::PipelineLayout,
module: &wgpu::ShaderModule,
fragment: &str,
constants: &[(&str, f64)],
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
let options = wgpu::PipelineCompilationOptions {
constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack sky"),
layout: Some(layout),
vertex: wgpu::VertexState {
module,
entry_point: Some("horizon_sky_vs_sky"),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// The sky's naga module and the layout of its pipelines: `globals` at group 0, the frame
/// at group 3.
fn build(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(globals);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack sky"),
bind_group_layouts: &groups,
..Default::default()
});
Ok((module, layout))
}
impl NativeSky {
/// The pipeline of the pack's sky `stage` with the option values `constants`, drawing
/// onto the world's target of `format`; `globals` is the terrain's group 0.
pub fn new(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
) -> Result<Self, String> {
let (module, layout) = build(device, globals, frame, stage)?;
let present = present_constants(&module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let pipeline = pipeline(
device,
&layout,
&module,
"horizon_sky_fs_pack",
&present,
format,
);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(Self {
pipeline,
label: stage.label.clone(),
})
}
/// Draws the sky in a pass whose group 0 is the terrain's.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, frame: &NativeFrame) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(GROUP, &frame.group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{Frame as FrameInputs, MANIFEST, Pack, StageKind, check};
fn stage(sky: &str) -> CheckedStage {
let mut manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n"
.to_owned();
if sky.contains("gain") {
manifest += "[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\
default = 1.0\nmin = 0.0\nmax = 2.0\n";
}
let manifest = manifest.as_str();
let files = [(MANIFEST, manifest), ("sky.wgsl", sky)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
assert_eq!(report.stages[0].kind, StageKind::Sky);
report.stages.remove(0)
}
#[test]
fn the_renderer_s_sky_takes_a_prefix_and_validates_with_a_pack() {
let sky = vanilla_sky();
for name in [
"Globals",
"SkyOut",
"view_ray",
"PI",
"sky_along_ray",
"linear_fog",
"fs_sky",
] {
assert!(sky.contains(&format!("{PREFIX}{name}")), "{name}");
}
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::default(),
)
.validate(&module)
.unwrap();
}
/// Vanilla's sky and two packs over the same globals: one that returns its input draws
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
#[test]
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
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 (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let mut globals: super::super::Globals = bytemuck::Zeroable::zeroed();
globals.inverse_view = glam::Mat4::IDENTITY.to_cols_array_2d();
globals.misc = [0.0, 0.0, 1.0, 1.0];
globals.fog_color = [0.2, 0.4, 0.6, 1.0];
globals.sky_color = [0.5, 0.7, 1.0, 1.0];
globals.fog_ends = [64.0, 0.0, 0.0, 0.0];
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: size_of::<super::super::Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&globals_buffer, 0, bytemuck::bytes_of(&globals));
let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &globals_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&FrameInputs::default()),
);
let gain = [("gain".to_owned(), 1.0)];
let draw = |stage: &CheckedStage, vanilla: bool| -> Vec<u8> {
let sky =
NativeSky::new(&device, &globals_layout, &frame, stage, &gain, format).unwrap();
let pipeline = if vanilla {
let (module, layout) = build(&device, &globals_layout, &frame, stage).unwrap();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
pipeline(&device, &layout, &module, "horizon_sky_fs_sky", &[], format)
} else {
sky.pipeline.clone()
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let (color_view, depth_view) = (
color.create_view(&Default::default()),
depth.create_view(&Default::default()),
);
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(0, &globals_group, &[]);
pass.set_pipeline(&pipeline);
pass.set_bind_group(GROUP, &frame.group, &[]);
pass.draw(0..3, 0..1);
}
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
(0..4)
.flat_map(|row| {
let start = row * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
bytes[start..start + 16].to_vec()
})
.collect()
};
let same = stage("fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n");
let vanilla = draw(&same, true);
assert_eq!(draw(&same, false), vanilla);
// The top row looks up into the sky disc, the bottom one down into the fog.
assert_ne!(vanilla[..4], vanilla[vanilla.len() - 4..]);
let inverse = stage(
"override gain: f32;\nfn sky(input: SkyInput) -> vec4<f32> { return vec4(vec3(gain) - input.color.rgb, 0.25); }\n",
);
let inverted = draw(&inverse, false);
for (got, want) in inverted.chunks(4).zip(vanilla.chunks(4)) {
assert_eq!(got[3], 255);
for i in 0..3 {
assert!(got[i].abs_diff(255 - want[i]) <= 1, "{got:?} {want:?}");
}
}
}
}
+3 -3
View File
@@ -297,8 +297,8 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// `HORIZON_SHADER_PACK_TIMING`: GPU time of each pass, in the log.
timing: Option<super::pack_timing::PassTiming>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -1430,7 +1430,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
timing: super::pack_timing::PassTiming::new(device, queue),
timing: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
+227 -68
View File
@@ -1,31 +1,53 @@
//! Developers' aid: `HORIZON_SHADER_PACK_TIMING=1` times every pass of a shader pack on the
//! GPU, one frame in `EVERY`, and logs each pass's median every `REPORT` timed frames. The
//! frame after a timed one waits for the GPU before reading the timestamps (Metal resolves
//! them without waiting for the passes that write them), so timing slows the game.
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const EVERY: u32 = 30;
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
const CAPACITY: u32 = 64;
/// Timed frames each pass's median is taken over.
const WINDOW: usize = 15;
pub(super) struct PassTiming {
enum Stage {
/// The next frame is timed.
Idle,
/// The frame being recorded is timed.
Recording,
/// Submitted; the flag is set once the GPU has finished it.
Running(Arc<AtomicBool>),
/// Resolved and copied; the flag is set once the copy is mapped.
Reading(Arc<AtomicBool>, u64),
}
pub struct PassTiming {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
read: wgpu::Buffer,
period_ns: f32,
frame: u32,
/// The passes timed in the frame being recorded or waiting to be read, in order.
stage: Stage,
/// The passes of the timed frame, in order.
names: Vec<String>,
timed: bool,
samples: Vec<(String, Vec<f32>)>,
/// Each pass's last `WINDOW` times, in milliseconds.
samples: Vec<(String, VecDeque<f32>)>,
/// Log the medians every `REPORT` timed frames.
log: bool,
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
}
impl PassTiming {
/// `None` when the device can't time passes.
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
if std::env::var_os("HORIZON_SHADER_PACK_TIMING").is_none()
|| !device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
{
if !device.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
return None;
}
let size = u64::from(CAPACITY) * 2 * 8;
@@ -52,51 +74,138 @@ impl PassTiming {
wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
),
period_ns: queue.get_timestamp_period(),
frame: 0,
stage: Stage::Idle,
names: Vec::new(),
timed: false,
samples: Vec::new(),
log: Self::logging(),
marker: None,
reports: 0,
})
}
/// At the start of a frame: reads the last timed frame's passes, and decides whether this
/// one is timed.
/// Whether `HORIZON_SHADER_PACK_TIMING` asks for timing without the F3 overlay.
pub fn logging() -> bool {
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*LOG.get_or_init(|| std::env::var_os("HORIZON_SHADER_PACK_TIMING").is_some())
}
/// At the start of a frame's passes: moves the last timed frame along, and decides whether
/// this one is timed.
pub fn begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
if self.timed && !self.names.is_empty() {
self.collect(device, queue);
match &self.stage {
// A timed frame that was never submitted: time this one instead.
Stage::Recording => self.stage = Stage::Idle,
Stage::Running(done) if done.load(Ordering::Acquire) => {
let bytes = self.names.len() as u64 * 16;
let mut encoder = device.create_command_encoder(&Default::default());
encoder.resolve_query_set(
&self.queries,
0..self.names.len() as u32 * 2,
&self.resolve,
0,
);
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes);
queue.submit([encoder.finish()]);
let mapped = Arc::new(AtomicBool::new(false));
let flag = mapped.clone();
self.read
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |r| {
flag.store(r.is_ok(), Ordering::Release)
});
self.stage = Stage::Reading(mapped, bytes);
}
Stage::Reading(mapped, bytes) if mapped.load(Ordering::Acquire) => {
let bytes = *bytes;
self.collect(bytes);
self.stage = Stage::Idle;
}
_ => {}
}
self.names.clear();
self.frame += 1;
self.timed = self.frame.is_multiple_of(EVERY);
if matches!(self.stage, Stage::Idle) {
self.names.clear();
self.stage = Stage::Recording;
}
}
/// Whether the frame being recorded is timed.
pub fn recording(&self) -> bool {
matches!(self.stage, Stage::Recording)
}
/// Timestamp writes for the pass `name`, in a timed frame.
pub fn writes(&mut self, name: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
if !self.timed || i >= CAPACITY {
if !matches!(self.stage, Stage::Recording) || i >= CAPACITY {
return None;
}
self.names.push(name.to_owned());
Some(wgpu::RenderPassTimestampWrites {
query_set: &self.queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
})
Some(i)
}
fn collect(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
// The timed frame first, then the resolve, so every timestamp is written.
_ = device.poll(wgpu::PollType::wait_indefinitely());
let count = self.names.len() as u32 * 2;
let bytes = u64::from(count) * 8;
let mut encoder = device.create_command_encoder(&Default::default());
encoder.resolve_query_set(&self.queries, 0..count, &self.resolve, 0);
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes);
queue.submit([encoder.finish()]);
/// A pass that ends once everything drawn into `target` before it has, in a timed frame:
/// the next pass counts from there, not from when the GPU queued it behind them.
pub fn mark(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
(target, format): super::timing::Target,
) {
if self.marker.as_ref().is_none_or(|(f, _)| *f != format) {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack timing mark"),
source: wgpu::ShaderSource::Wgsl(super::timing::MARKER.into()),
});
let pipeline = super::timing::end_pipeline(device, &module, format);
self.marker = Some((format, pipeline));
}
// An unnamed pass: its end counts, it is never listed.
let Some(i) = self.push("") else {
return;
};
let (_, pipeline) = self.marker.as_ref().expect("made above");
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack timing mark"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: Some(stamps(&self.queries, i)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_scissor_rect(0, 0, 1, 1);
pass.draw(0..3, 0..1);
}
/// After the frame is submitted.
pub fn submitted(&mut self, queue: &wgpu::Queue) {
if !matches!(self.stage, Stage::Recording) {
return;
}
if self.names.is_empty() {
self.stage = Stage::Idle;
return;
}
let done = Arc::new(AtomicBool::new(false));
let flag = done.clone();
queue.on_submitted_work_done(move || flag.store(true, Ordering::Release));
self.stage = Stage::Running(done);
}
fn collect(&mut self, bytes: u64) {
let slice = self.read.slice(..bytes);
slice.map_async(wgpu::MapMode::Read, |_| {});
_ = device.poll(wgpu::PollType::wait_indefinitely());
let Ok(data) = slice.get_mapped_range() else {
return;
};
@@ -108,41 +217,91 @@ impl PassTiming {
.collect();
drop(data);
self.read.unmap();
// Passes overlap on tiled GPUs (a pass begins before the one before it ends): a pass
// costs the time from the later of its beginning and the previous pass's end.
let mut previous_end = 0;
for (name, pair) in self.names.iter().zip(stamps.as_chunks::<2>().0) {
if pair[0] == 0 || pair[1] < pair[0] {
let times = pass_times(&stamps, self.period_ns);
for (name, ms) in self.names.iter().zip(times) {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
let i = match self.samples.iter().position(|s| s.0 == *name) {
Some(i) => i,
None => {
self.samples.push((name.clone(), VecDeque::new()));
self.samples.len() - 1
}
};
let window = &mut self.samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
let from = pair[0].max(previous_end).min(pair[1]);
previous_end = pair[1];
let ms = (pair[1] - from) as f32 * self.period_ns / 1e6;
match self.samples.iter_mut().find(|s| s.0 == *name) {
Some(s) => s.1.push(ms),
None => self.samples.push((name.clone(), vec![ms])),
}
window.push_back(ms);
}
self.reports += 1;
if self.reports.is_multiple_of(REPORT) {
self.report();
if self.log && self.reports.is_multiple_of(REPORT) {
let medians = self.medians();
let total: f32 = medians.iter().map(|m| m.1).sum();
let parts: Vec<String> = medians
.iter()
.map(|(n, ms)| format!("{n} {ms:.3}"))
.collect();
log::info!(
"shader pack GPU time per pass (median ms of the last {WINDOW} timed frames): {} = {total:.3}",
parts.join(", ")
);
}
}
fn report(&mut self) {
let mut total = 0.0;
let mut parts = Vec::new();
// A pass that did not run in this window (no entities, the sky out of sight) is left out.
for (name, v) in self.samples.iter_mut().filter(|s| !s.1.is_empty()) {
v.sort_by(f32::total_cmp);
let median = v[v.len() / 2];
total += median;
parts.push(format!("{name} {median:.3}"));
v.clear();
}
log::info!(
"shader pack GPU time per pass (median ms of {REPORT} frames): {} = {total:.3}",
parts.join(", ")
);
/// Each pass's median time in milliseconds over its last timed frames, in the order the
/// passes first ran.
pub fn medians(&self) -> Vec<(String, f32)> {
self.samples
.iter()
.filter(|s| !s.1.is_empty())
.map(|(name, v)| {
let mut v: Vec<f32> = v.iter().copied().collect();
v.sort_by(f32::total_cmp);
(name.clone(), v[v.len() / 2])
})
.collect()
}
}
/// Pass `i`'s beginning and end timestamps.
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
wgpu::RenderPassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
let mut previous_end = 0;
stamps
.as_chunks::<2>()
.0
.iter()
.map(|&[begin, end]| {
if begin == 0 || end < begin {
return None;
}
let from = begin.max(previous_end).min(end);
previous_end = end;
Some((end - from) as f32 * period_ns / 1e6)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::pass_times;
#[test]
fn overlapping_passes_cost_from_the_previous_end() {
// Pass 2 begins before pass 1 ends; pass 3 was never stamped.
let stamps = [1_000_000, 3_000_000, 2_000_000, 5_000_000, 0, 0];
assert_eq!(pass_times(&stamps, 1.0), [Some(2.0), Some(2.0), None]);
}
}
+2 -2
View File
@@ -60,7 +60,7 @@ struct Markers {
/// The start's one thread; the end's triangle over the whole target (the
/// pass's scissor keeps one pixel), shading nothing it writes.
const MARKER: &str =
pub(super) const MARKER: &str =
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
}
@@ -254,7 +254,7 @@ impl GpuTimer {
}
/// The end marker's pipeline for a `format` target, every write masked.
fn end_pipeline(
pub(super) fn end_pipeline(
device: &wgpu::Device,
module: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
@@ -0,0 +1,124 @@
//! Copies of the renderer's own WGSL that run beside a shader pack's code in one module (the
//! sky and material stages of Horizon's packs, docs/shaders.md): every name such a copy
//! declares takes a prefix starting with `horizon_`, which the pack check refuses in a pack,
//! so no pack name can meet one of Horizon's.
/// terrain.wgsl, which the copies are taken from.
pub const TERRAIN: &str = include_str!("../terrain.wgsl");
/// The text of `source` from the line starting with `start` up to (not including) the one
/// starting with `end`.
pub fn section<'a>(source: &'a str, start: &str, end: &str) -> &'a str {
let from = source
.find(&format!("\n{start}"))
.expect("the source has the section")
+ 1;
let to = from
+ source[from..]
.find(&format!("\n{end}"))
.expect("the source ends the section")
+ 1;
&source[from..to]
}
/// The names `text` declares at its top level: functions, structs, constants, overrides and
/// module variables (bindings).
fn declared(text: &str) -> Vec<&str> {
let mut names = Vec::new();
for line in text.lines() {
// Top level only: a function's own `var` and `let` stay as they are.
if line.starts_with(char::is_whitespace) {
continue;
}
let mut line = line;
while let Some(rest) = line.strip_prefix('@') {
// An attribute: `@vertex`, `@group(0)`, `@binding(1)`.
let end = rest.find(' ').unwrap_or(rest.len());
line = rest[end..].trim_start();
}
let rest = ["fn ", "struct ", "const ", "override ", "var "]
.iter()
.find_map(|k| line.strip_prefix(k))
.or_else(|| {
// `var<uniform> name`, `var<storage, read> name`.
let rest = line.strip_prefix("var<")?;
Some(rest[rest.find('>')? + 1..].trim_start())
});
if let Some(rest) = rest {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
names.push(&rest[..end]);
}
}
names
}
/// `text` with every name it declares at its top level, and every use of one, prefixed by
/// `prefix`. Comments are rewritten too, which is harmless.
pub fn prefixed(text: &str, prefix: &str) -> String {
let names = declared(text);
let mut out = String::with_capacity(text.len() * 2);
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
if names.contains(&word.as_str()) {
out.push_str(prefix);
}
out.push_str(word);
word.clear();
};
for c in text.chars() {
if c.is_ascii_alphanumeric() || c == '_' {
word.push(c);
} else {
flush(&mut word, &mut out);
out.push(c);
}
}
flush(&mut word, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefixes_top_level_names_and_their_uses_only() {
let text = "struct S { a: f32, }\n\
@group(0) @binding(0) var<uniform> g: S;\n\
@group(1) @binding(2) var<storage, read> list: array<u32>;\n\
override N: u32 = 1u;\n\
const K: f32 = 2.0;\n\
@fragment fn f(x: f32) -> @location(0) vec4<f32> {\n \
var a = g.a * K;\n \
return vec4(a + f32(list[N]) + x);\n\
}\n";
let out = prefixed(text, "p_");
assert_eq!(
out,
"struct p_S { a: f32, }\n\
@group(0) @binding(0) var<uniform> p_g: p_S;\n\
@group(1) @binding(2) var<storage, read> p_list: array<u32>;\n\
override p_N: u32 = 1u;\n\
const p_K: f32 = 2.0;\n\
@fragment fn p_f(x: f32) -> @location(0) vec4<f32> {\n \
var a = p_g.a * p_K;\n \
return vec4(a + f32(p_list[p_N]) + x);\n\
}\n"
);
// Every top-level declaration of terrain.wgsl is found.
let names = declared(TERRAIN);
for name in [
"Globals",
"globals",
"sprites_7",
"SPRITE_SPLIT",
"block_slots",
"fs_sky",
] {
assert!(names.contains(&name), "{name}");
}
assert!(!names.contains(&"out"));
}
}
+9 -1
View File
@@ -261,6 +261,10 @@ pub struct Graphics {
pub battery: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
pub shader_pack: String,
}
impl Default for Graphics {
fn default() -> Self {
@@ -284,6 +288,7 @@ impl Default for Graphics {
fxaa: false,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
}
}
}
@@ -334,6 +339,7 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
g.overlay = match value {
"fps" => Overlay::Fps,
@@ -348,7 +354,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -375,6 +381,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.battery),
self.api.name(),
self.shader_pack,
)
}
pub fn save(&self, dir: &Path) {
@@ -471,6 +478,7 @@ mod tests {
fxaa: true,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
+13 -6
View File
@@ -2175,17 +2175,24 @@ impl Hub {
if g.spectator() || g.sleeping {
return (false, false);
}
let height = if g.fall_flying {
0.6
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
1.5
(0.3, 1.5)
} else {
1.8
(0.3, 1.8)
};
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + height, p[2] + 0.3),
Vec3::new(p[0] - half, p[1], p[2] - half),
Vec3::new(p[0] + half, p[1] + height, p[2] + half),
)
};
let (old, new) = (at(g.position), at(to));
+1
View File
@@ -168,6 +168,7 @@ pub mod sculk;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod settings;
pub mod shader_pack_list;
pub mod shadows;
pub mod signs;
pub mod skins;
+6
View File
@@ -4,7 +4,13 @@ mod game;
use horizon_client::{crash, logging};
/// The mod API level this game serves, kept in the program for the launcher
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
/// reads it, so the linker keeps it.
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
fn main() {
std::hint::black_box(&MOD_API_LEVEL);
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
+1 -1
View File
@@ -44,7 +44,7 @@ use std::{
pub(crate) mod environment;
mod fluids;
pub use fluids::{FluidResources, LAVA, PlayerFluids, WATER};
pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER};
pub struct MotionResources {
pub attributes: Arc<Catalog>,
+6 -5
View File
@@ -103,10 +103,10 @@ fn is_tagged(kind: FluidKind, tag: FluidTagId) -> bool {
}
impl PlayerMotion {
pub fn fluid_height(&self, tag: FluidTagId) -> f64 {
self.fluids.interaction.fluid_height(&Membership, tag)
self.fluids.interaction.fluid_height(&FluidTags, tag)
}
pub fn is_eye_in_fluid(&self, tag: FluidTagId) -> bool {
self.fluids.interaction.is_eye_in_fluid(&Membership, tag)
self.fluids.interaction.is_eye_in_fluid(&FluidTags, tag)
}
/// `LocalPlayer.isUnderWater`: `wasUnderwater`, the eye sample `Player.tick`
/// takes before the base tick (`Entity.isUnderWater` also needs the body
@@ -116,11 +116,12 @@ impl PlayerMotion {
}
/// `Entity.isInLava`.
pub fn is_in_lava(&self) -> bool {
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&Membership, LAVA)
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&FluidTags, LAVA)
}
}
struct Membership;
impl FluidMembership for Membership {
/// Which fluids `FluidTags.WATER` and `FluidTags.LAVA` hold.
pub struct FluidTags;
impl FluidMembership for FluidTags {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
is_tagged(kind, tag)
}
+88 -16
View File
@@ -25,7 +25,7 @@ pub struct Moved {
pub name: &'static str,
}
pub const MOVED: [Moved; 2] = [
pub const MOVED: [Moved; 4] = [
Moved {
id: "fps_overlay",
name: "FPS counter",
@@ -34,13 +34,23 @@ pub const MOVED: [Moved; 2] = [
id: "keystrokes",
name: "Keystrokes",
},
Moved {
id: "zoom",
name: "Zoom",
},
Moved {
id: "minimap",
name: "Minimap",
},
];
impl Moved {
fn was_on(&self, e: &Extras) -> bool {
match self.id {
"fps_overlay" => e.fps,
_ => e.keystrokes,
"keystrokes" => e.keystrokes,
"zoom" => e.zoom,
_ => e.minimap,
}
}
/// The extra's settings as the mod keeps them (its storage keys).
@@ -51,10 +61,33 @@ impl Moved {
("corner", e.fps_corner.name().into()),
("lows", flag(e.fps_lows)),
],
_ => vec![
"keystrokes" => vec![
("corner", e.keystrokes_corner.name().into()),
("cps", flag(e.keystrokes_cps)),
],
"zoom" => vec![
("level", e.zoom_level.to_string()),
("smooth", flag(e.zoom_smooth)),
("sensitivity", flag(e.zoom_sensitivity)),
],
_ => vec![
("corner", e.minimap_corner.name().into()),
("size", e.minimap_size.to_string()),
(
"shape",
if e.minimap_round { "round" } else { "square" }.into(),
),
("zoom", e.minimap_zoom.to_string()),
("rotate", flag(e.minimap_rotate)),
],
}
}
/// The extra's key as the mod's key (`horizon-mods.txt`), when the
/// player had changed it from the mod's default.
pub fn keys(&self, e: &Extras) -> Vec<(&'static str, String)> {
match self.id {
"zoom" if e.zoom_key != "key.keyboard.z" => vec![("zoom", e.zoom_key.clone())],
_ => Vec::new(),
}
}
/// The line the player reads once it is installed.
@@ -131,21 +164,21 @@ pub fn fetch_index() -> Result<Index, String> {
/// Installs `m` into the game folder `dir` (where `options.txt` is), for a
/// player whose extras were `e`: its newest version for this game that
/// wasn't taken down, checked against the index (size, SHA-256, signature,
/// manifest), as `horizon-mods/<id>.hmod`, turned on, with the extra's
/// settings in its storage unless the mod has its own already.
/// wasn't taken down and that this Horizon's mod API level runs, checked
/// against the index (size, SHA-256, signature, manifest), as
/// `horizon-mods/<id>.hmod`, turned on, with the extra's settings in its
/// storage unless the mod has its own already.
pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> {
let project = index
.project(m.id)
.ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?;
let version = project
.versions
.iter()
.find(|v| {
v.game == horizon_core::version::VERSION_ID
&& index.revoked(&project.id, &v.file.sha256).is_none()
})
.ok_or_else(|| format!("{} has no version for this game", m.id))?;
let version = index
.installable(
project,
horizon_core::version::VERSION_ID,
horizon_mods::api_level::LEVEL,
)
.ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?;
let mods = dir.join(crate::mod_list::DIR);
fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?;
let target = mods.join(format!("{}.hmod", m.id));
@@ -171,6 +204,12 @@ pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), S
}
let mut list = ModList::load(dir);
list.enabled.insert(m.id.to_owned());
// A key the player already chose for the mod stays.
let keys = list.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(e) {
keys.entry(name.to_owned()).or_insert(key);
}
list.keys.retain(|_, k| !k.is_empty());
list.save(dir);
Ok(())
}
@@ -184,7 +223,7 @@ mod tests {
fn only_extras_that_were_on_are_installed_and_each_once() {
let mut e = Extras::default();
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e).len(), 2);
assert_eq!(nothing_to_do(&e).len(), 4);
e.fps = true;
e.fps_corner = Corner::BottomRight;
e.fps_lows = true;
@@ -199,7 +238,40 @@ mod tests {
);
e.moved.push("fps_overlay".into());
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e), [&MOVED[1]]);
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]);
// Zoom: its settings, and its key only when the player changed it.
e.zoom = true;
e.zoom_level = 8;
e.zoom_smooth = false;
assert_eq!(pending(&e), [&MOVED[2]]);
assert_eq!(
MOVED[2].settings(&e),
[
("level", "8".into()),
("smooth", "false".into()),
("sensitivity", "true".into())
]
);
assert!(MOVED[2].keys(&e).is_empty());
e.zoom_key = "key.mouse.4".into();
assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]);
// The minimap: its five settings, in the extra's values.
e.minimap = true;
e.minimap_corner = Corner::BottomLeft;
e.minimap_size = 128;
e.minimap_round = true;
e.minimap_zoom = 0.5;
assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]);
assert_eq!(
MOVED[3].settings(&e),
[
("corner", "bottom_left".into()),
("size", "128".into()),
("shape", "round".into()),
("zoom", "0.5".into()),
("rotate", "false".into())
]
);
assert_eq!(
MOVED[0].notice(),
"FPS counter moved to the mod browser: installed for you"
+89 -24
View File
@@ -863,6 +863,8 @@ pub enum Page {
Font,
Packs,
Mods,
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -893,6 +895,9 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
ModSettings(usize),
@@ -947,6 +952,11 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
ListShaderPacks,
/// A shader pack was picked or turned off: save and apply it.
ShaderPack,
OpenShaderPackFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
@@ -1092,6 +1102,9 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
@@ -1183,6 +1196,7 @@ impl OptionsUi {
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
@@ -1299,23 +1313,30 @@ impl OptionsUi {
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
),
Row::Buttons(
open("Controller Settings...", Page::Controller),
Some(self.extra_button(s, crate::extras::Item::DiscordPresence)),
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("AI Agent...", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai).then(|| {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
}),
)
.collect()
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
@@ -1532,6 +1553,26 @@ impl OptionsUi {
})
.collect()
}
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::PickShaderPack(i)), None)
})
.collect()
}
_ => {
let _ = s;
Vec::new()
@@ -2017,13 +2058,18 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
if page == Page::Mods {
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal("Open Mod Folder"),
Act::OpenModFolder,
Text::literal(label),
act,
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
@@ -2475,7 +2521,12 @@ impl OptionsUi {
} => {
events.push(Event::ButtonClick);
match act {
Act::Push(p) => self.push(p),
Act::Push(p) => {
if p == Page::ShaderPacks {
events.push(Event::ListShaderPacks);
}
self.push(p)
}
Act::Done => events.extend(self.back(s)),
Act::Cycle(opt) => {
opt.cycle(s, right, framebuffer);
@@ -2520,6 +2571,17 @@ impl OptionsUi {
events.push(Event::ModSettings(i));
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
s.horizon.shader_pack = if s.horizon.shader_pack == pack.file {
String::new()
} else {
pack.file.clone()
};
events.push(Event::ShaderPack);
}
}
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -3296,26 +3358,29 @@ mod tests {
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Zoom)),
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
vec![Event::ButtonClick, Event::Changed]
);
assert!(s.extras.zoom);
assert!(s.extras.sort);
assert_eq!(
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::ZoomKey)),
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
vec![Event::ButtonClick]
);
assert_eq!(ui.selected_key, Some(crate::extras::ZOOM_KEY));
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyC, None),
vec![Event::Changed]
);
assert_eq!(s.extras.zoom_key, "key.keyboard.c");
assert_eq!(s.extras.sort_key, "key.keyboard.c");
assert_eq!(
crate::extras::Extras::parse(&s.extras.serialize()),
s.extras
);
ui.scroll(&s, size, -1000.0);
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
// In a window too short for the page, the wheel scrolls to its end.
let short = (480.0, 180.0);
ui.scroll(&s, short, -1000.0);
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
assert!(content > bottom - top);
assert_eq!(ui.scroll, content - (bottom - top));
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
@@ -3867,7 +3932,7 @@ mod tests {
#[test]
fn extras_and_vanilla_bindings_accept_mouse_buttons() {
use crate::extras::{Extras, SORT_KEY, ZOOM_KEY};
use crate::extras::{Extras, SORT_KEY};
use winit::event::MouseButton;
for button in [
MouseButton::Left,
@@ -3876,7 +3941,7 @@ mod tests {
MouseButton::Back,
MouseButton::Forward,
] {
for name in [SORT_KEY, ZOOM_KEY, "key.jump"] {
for name in [SORT_KEY, "key.jump"] {
let mut ui = OptionsUi::default();
let mut s = Settings::default();
ui.selected_key = Some(name);
@@ -3885,7 +3950,6 @@ mod tests {
let value = keybinds::mouse_name(button);
match name {
SORT_KEY => assert_eq!(s.extras.sort_key, value),
ZOOM_KEY => assert_eq!(s.extras.zoom_key, value),
_ => assert_eq!(s.keys.get(name), value),
}
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
@@ -4064,6 +4128,7 @@ mod tests {
[
Page::Graphics,
Page::GraphicsAdvanced,
Page::ShaderPacks,
Page::Controller,
Page::Extras,
Page::Mods,
+5
View File
@@ -182,6 +182,9 @@ pub struct Settings {
pub extras: crate::extras::Extras,
/// The Horizon mods turned on (`horizon-mods.txt`), all off by default.
pub mods: crate::mod_list::ModList,
/// The packs in `shaderpacks/`, listed when the Shader Packs page opens; the pick is
/// `horizon.shader_pack`.
pub shader_packs: Vec<crate::shader_pack_list::Entry>,
}
impl Default for Settings {
fn default() -> Self {
@@ -255,6 +258,7 @@ impl Default for Settings {
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
}
}
}
@@ -415,6 +419,7 @@ impl Settings {
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
auto_jump: f.bool("autoJump"),
language: f.string("lang"),
force_unicode: f.bool("forceUnicodeFont"),
@@ -0,0 +1,109 @@
//! The shader packs in the `shaderpacks` folder beside `options.txt`, as Options > Horizon
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
pub const DIR: &str = "shaderpacks";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// A folder with `horizon-shaders.toml`, or a `.hshader`.
Horizon,
/// A folder with a `shaders` folder, or a `.zip`.
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
pub fn scan(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
packs.sort_by(|a, b| {
(a.kind == Kind::Classic, a.title.to_lowercase())
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
});
packs
}
fn entry(path: &Path) -> Option<Entry> {
let file = path.file_name()?.to_str()?.to_owned();
if file.starts_with('.') {
return None;
}
if horizon_shader_api::package::is_pack(path) {
// Its manifest's name; a pack that can't be read is still listed, under its file
// name: picking it says why in the chat.
let manifest = if path.is_dir() {
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
.ok()
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
} else {
horizon_shader_api::Pack::read(path)
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
});
}
let zip = path.is_file()
&& path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
(zip || path.join("shaders").is_dir()).then(|| Entry {
title: file.clone(),
file,
kind: Kind::Classic,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let d = dir.as_path();
let manifest = "schema = 1\nid = \"warm\"\nname = \"Warm Light\"\nversion = \"1\"\n\
api = \"0.1\"\n[[post]]\nfile = \"p.wgsl\"\n";
std::fs::create_dir(d.join("warm")).unwrap();
std::fs::write(d.join("warm").join(horizon_shader_api::MANIFEST), manifest).unwrap();
std::fs::create_dir_all(d.join("Classic Folder/shaders")).unwrap();
std::fs::write(d.join("archive.zip"), b"").unwrap();
std::fs::write(d.join("notes.txt"), b"").unwrap();
std::fs::create_dir(d.join("empty")).unwrap();
std::fs::create_dir_all(d.join(".hidden/shaders")).unwrap();
let packs = scan(d);
let listed: Vec<(&str, &str, Kind)> = packs
.iter()
.map(|e| (e.file.as_str(), e.title.as_str(), e.kind))
.collect();
assert_eq!(
listed,
[
("warm", "Warm Light", Kind::Horizon),
("archive.zip", "archive.zip", Kind::Classic),
("Classic Folder", "Classic Folder", Kind::Classic),
]
);
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
}
+6 -2
View File
@@ -639,8 +639,12 @@ impl Streaming {
self.lit.remove(&(x, z));
self.meshed.remove(&(x, z));
self.lighting.remove(&(x, z));
self.meshing.retain(|k, _| (k.0, k.2) != (x, z));
self.waiting.retain(|k| (k.0, k.2) != (x, z));
// By key, not by a pass over every pending section: a moved or
// shrunk storage forgets thousands of columns in one frame.
for sy in 0..SECTIONS as i32 {
self.meshing.remove(&(x, sy, z));
self.waiting.remove(&(x, sy, z));
}
world.dirty_sections.take_column(x, z);
world.chunks.remove(&(x, z))
}
+7 -3
View File
@@ -227,8 +227,9 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
}
return best;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
// gfx/native_sky.rs builds that stage from this section).
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
@@ -238,7 +239,10 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
if w > 0.0 {
color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a);
}
return vec4(color, 1.0);
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
}
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
let d = view_ray(in.ndc);
+267 -8
View File
@@ -17,12 +17,24 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
@@ -80,6 +92,27 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -95,6 +128,26 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "bytes"
version = "1.12.1"
@@ -117,6 +170,21 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"unicode-width",
]
[[package]]
name = "core_detect"
version = "1.0.0"
@@ -147,6 +215,12 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -165,7 +239,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -187,7 +261,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -236,6 +310,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -302,11 +382,28 @@ dependencies = [
"wasip2",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"num-traits",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"serde",
"serde_core",
]
[[package]]
name = "hmac"
@@ -340,12 +437,27 @@ dependencies = [
name = "horizon-mods"
version = "0.1.0"
dependencies = [
"horizon-shader-api",
"log",
"minisign-verify",
"serde",
"serde_json",
"sha2",
"toml",
"wasmparser",
"zip",
]
[[package]]
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
@@ -540,6 +652,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
@@ -554,6 +668,12 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -634,6 +754,54 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
"bitflags",
"cfg-if",
"cfg_aliases",
"codespan-reporting",
"half",
"hashbrown",
"indexmap",
"libm",
"log",
"naga-types",
"num-traits",
"once_cell",
"rustc-hash",
"spirv",
"thiserror",
"unicode-ident",
]
[[package]]
name = "naga-types"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown",
"indexmap",
"rustc-hash",
"thiserror",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -699,6 +867,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustls"
version = "0.23.46"
@@ -752,6 +926,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@@ -779,7 +959,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -894,6 +1074,15 @@ version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
dependencies = [
"bitflags",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -906,6 +1095,17 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -931,7 +1131,27 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
@@ -968,7 +1188,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -1049,6 +1269,12 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1111,6 +1337,19 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasmparser"
version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -1254,10 +1493,30 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -1275,7 +1534,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
"synstructure",
]
@@ -1315,7 +1574,7 @@ checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
+3 -2
View File
@@ -342,11 +342,12 @@ async fn what_the_form_refuses() {
);
let (status, json) = client.submit(&csrf, &f, &file).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
// Shader packs aren't open.
// A shader pack runs the game's own check: a mod's file isn't one.
let mut f = form();
f["kind"] = json!("shader");
let (status, json) = client.submit(&csrf, &f, &file).await;
refused(status, &json, "shader packs open");
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
refused(status, &json, "horizon-shaders.toml: missing");
// An update by someone who isn't an owner.
let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n";
mock.lock()
@@ -0,0 +1,16 @@
[package]
name = "welcome"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Example Horizon server mod: greets players, announces newcomers, /online and /mined"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = { path = "../.." }
[lints]
workspace = true
@@ -0,0 +1,8 @@
id = "welcome"
name = "Welcome"
version = "0.1.0"
description = "Greets each player who joins, announces a newcomer to everyone, /online lists who is on, /mined counts the blocks you broke."
authors = ["Horizon"]
api = "0.3"
side = "server"
capabilities = ["players", "storage"]
@@ -0,0 +1,84 @@
//! Example Horizon server mod (`docs/mods.md`, "Server mods"): greets each
//! player who joins, announces a newcomer to everyone (the players it has
//! seen are kept in `storage`), `/online` lists who is on and where, and
//! `/mined` counts the blocks the player broke since the server started.
use horizon_mod_sdk::{
server::{BlockPos, Player, Registration, ServerMod, players},
storage,
};
use std::collections::HashMap;
struct Welcome {
/// Ids of every player seen, one per line in storage key `seen`.
seen: Vec<String>,
mined: HashMap<String, u32>,
}
impl ServerMod for Welcome {
fn init() -> Result<(Self, Registration), String> {
let seen = storage::get("seen")
.map(|b| {
String::from_utf8_lossy(&b)
.lines()
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
let hooks = Registration::new()
.command("online")
.command("mined")
.on_joins()
.on_blocks();
Ok((
Welcome {
seen,
mined: HashMap::new(),
},
hooks,
))
}
fn player_joined(&mut self, player: &Player) {
let others = players::online().len() - 1;
players::tell(
&player.id,
&format!("Welcome, {}! {others} other player(s) online.", player.name),
);
if !self.seen.contains(&player.id) {
self.seen.push(player.id.clone());
let _ = storage::set("seen", self.seen.join("\n").as_bytes());
players::broadcast(&format!("{} is here for the first time", player.name));
}
}
fn command(
&mut self,
player: &Player,
name: &str,
_args: &str,
) -> Result<Option<String>, String> {
Ok(Some(match name {
"online" => players::online()
.iter()
.map(|p| {
format!(
"{} ({})",
p.name,
p.dimension.trim_start_matches("minecraft:")
)
})
.collect::<Vec<_>>()
.join(", "),
_ => {
let n = self.mined.get(&player.id).copied().unwrap_or(0);
format!("You broke {n} block(s)")
}
}))
}
fn block_broken(&mut self, player: &Player, _pos: BlockPos, _state: &str) {
*self.mined.entry(player.id.clone()).or_default() += 1;
}
}
horizon_mod_sdk::export_server_mod!(Welcome);
@@ -0,0 +1,31 @@
# /online lists the players and their dimension; /mined counts the acting player's blocks.
[[step]]
join = "Alex"
expect.commands = ["online", "mined"]
[[step]]
join = "Steve"
as = "Steve"
player = { dimension = "minecraft:the_nether" }
command = "online"
expect.feedback = "Alex (overworld), Steve (the_nether)"
[[step]]
as = "Alex"
break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }
[[step]]
as = "Alex"
break = { x = 2, y = 63, z = 0, state = "minecraft:dirt" }
# A step's command comes before its break and place.
[[step]]
as = "Alex"
command = "mined"
expect.feedback = "You broke 2 block(s)"
[[step]]
as = "Steve"
command = "mined"
expect.feedback = "You broke 0 block(s)"
@@ -0,0 +1,21 @@
# Joins are greeted; a newcomer is announced once, even after a reload (storage).
[[step]]
join = "Alex"
expect.told = ["Alex: Welcome, Alex! 0 other player(s) online."]
expect.broadcast = ["Alex is here for the first time"]
[[step]]
join = "Steve"
expect.told = ["Steve: Welcome, Steve! 1 other player(s) online."]
expect.broadcast = ["Steve is here for the first time"]
[[step]]
leave = "Alex"
expect.told = []
[[step]]
reload = true
join = "Alex"
expect.told = ["Alex: Welcome, Alex! 1 other player(s) online."]
expect.broadcast = []
+151 -1
View File
@@ -21,7 +21,8 @@
//! ```
//!
//! Every interface but `log` is a capability: list the ones the mod uses in
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`).
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`;
//! a server mod `storage` and `players`). Server mods: [`server`].
//!
//! Widgets (capability `ui`): a mod shows a screen it owns with
//! [`ui::open_screen`] and a page in Options > Mods with
@@ -173,3 +174,152 @@ macro_rules! export_mod {
};
};
}
/// Server mods (manifest `side = "server"`, `api = "0.3"`, experimental): a
/// type implementing [`ServerMod`](server::ServerMod), exported with
/// [`export_server_mod!`]. It runs where the world is simulated, and reads
/// and messages the players with capability `players`.
pub mod server {
wit_bindgen::generate!({
path: "wit",
world: "horizon-server-mod",
pub_export_macro: true,
export_macro_name: "__export_horizon_server_mod",
default_bindings_module: "horizon_mod_sdk::server",
with: {
"horizon:mods/[email protected]": crate::log,
"horizon:mods/[email protected]": crate::storage,
},
});
pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration};
pub use horizon::mods::players::{self, GameMode, Player};
/// The server's calls into a mod. Only the hooks [`Registration`] asks
/// for are called; the others may stay as their defaults.
pub trait ServerMod: Sized + 'static {
/// Once, after loading: the mod's state and the hooks it wants. An
/// error keeps the mod off and is logged.
fn init() -> Result<(Self, Registration), String>;
/// Every server tick, when [`Registration::tick`] is set.
fn tick(&mut self, _tick: u64) {}
/// When [`Registration::joins`] is set.
fn player_joined(&mut self, _player: &Player) {}
fn player_left(&mut self, _player: &Player) {}
/// A chat message a player sent, when [`Registration::chat`] is set.
fn chat(&mut self, _player: &Player, _text: &str) {}
/// `/name args` typed by `player`. `Ok(Some(text))` is their
/// feedback, `Err(text)` their error in red.
fn command(
&mut self,
_player: &Player,
_name: &str,
_args: &str,
) -> Result<Option<String>, String> {
Err("This command does nothing".into())
}
/// When [`Registration::blocks`] is set: `state` is what was there.
fn block_broken(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
fn block_placed(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
}
impl Registration {
/// No commands and no hooks.
pub fn new() -> Self {
Self {
commands: Vec::new(),
tick: false,
joins: false,
chat: false,
blocks: false,
}
}
/// Adds `/name`.
pub fn command(mut self, name: &str) -> Self {
self.commands.push(name.into());
self
}
pub fn on_tick(mut self) -> Self {
self.tick = true;
self
}
pub fn on_joins(mut self) -> Self {
self.joins = true;
self
}
pub fn on_chat(mut self) -> Self {
self.chat = true;
self
}
pub fn on_blocks(mut self) -> Self {
self.blocks = true;
self
}
}
impl Default for Registration {
fn default() -> Self {
Self::new()
}
}
}
/// Exports a [`ServerMod`](server::ServerMod) as the component's
/// `server-events` interface, as [`export_mod!`] does for a client mod.
#[macro_export]
macro_rules! export_server_mod {
($t:ty) => {
const _: () = {
use ::std::cell::RefCell;
use $crate::server::{BlockPos, Player, Registration, ServerMod};
::std::thread_local! {
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
}
fn with(f: impl FnOnce(&mut $t)) {
STATE.with(|s| {
if let Some(m) = s.borrow_mut().as_mut() {
f(m)
}
})
}
struct Export;
impl $crate::server::__Guest for Export {
fn init() -> Result<Registration, String> {
let (m, registration) = <$t as ServerMod>::init()?;
STATE.with(|s| *s.borrow_mut() = Some(m));
Ok(registration)
}
fn tick(tick: u64) {
with(|m| m.tick(tick))
}
fn player_joined(player: Player) {
with(|m| m.player_joined(&player))
}
fn player_left(player: Player) {
with(|m| m.player_left(&player))
}
fn chat(player: Player, text: String) {
with(|m| m.chat(&player, &text))
}
fn command(
player: Player,
name: String,
args: String,
) -> Result<Option<String>, String> {
let mut out = Err("The mod is not running".to_string());
with(|m| out = m.command(&player, &name, &args));
out
}
fn block_broken(player: Player, pos: BlockPos, state: String) {
with(|m| m.block_broken(&player, pos, &state))
}
fn block_placed(player: Player, pos: BlockPos, state: String) {
with(|m| m.block_placed(&player, pos, &state))
}
}
#[cfg(target_arch = "wasm32")]
$crate::server::__export_horizon_server_mod!(Export);
#[cfg(not(target_arch = "wasm32"))]
let _ = <Export as $crate::server::__Guest>::init;
};
};
}
+109
View File
@@ -364,6 +364,27 @@ interface render {
/// wins) leave the camera vanilla's. With several mods, the strongest
/// zoom counts.
set-zoom: func(zoom: zoom);
record minimap {
corner: corner,
/// Its side in GUI pixels, 16 to 256.
size: f32,
/// World blocks per map pixel, 0.5 to 4; the map is 128 × 128 pixels.
blocks-per-pixel: f32,
/// The map turns with the player, who faces up, and an N on its rim
/// marks north. Otherwise north is up and the player's arrow turns.
rotate: bool,
/// Round, the corners left clear; otherwise square.
round: bool,
}
/// Shows a map of the world around the player in `corner`, stacked with
/// the game's overlays and the mods' panels, until the next call (none
/// hides it). It looks like a map item: vanilla's map colours with their
/// height shading and water depth, and the player's arrow. Underground,
/// under a roof and in the Nether it maps the floor around the player's
/// height. The game keeps the map's tiles and refreshes them within its
/// own frame budget; hidden, they are dropped. One map shows at a time:
/// the first loaded mod's.
set-minimap: func(minimap: option<minimap>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
@@ -391,6 +412,84 @@ interface events {
chat: func(text: string);
}
/// Capability `players`, server mods only (manifest `api = "0.3"`,
/// experimental): the players on the server, and messages to them.
interface players {
enum game-mode {
survival,
creative,
adventure,
spectator,
}
record player {
/// The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
id: string,
name: string,
/// Feet position, in blocks.
x: f64,
y: f64,
z: f64,
/// `minecraft:overworld`, `minecraft:the_nether`, ...
dimension: string,
game-mode: game-mode,
/// Hit points as vanilla counts them: 20 is full health without effects.
health: f32,
}
/// The players online, in the order they joined.
online: func() -> list<player>;
/// A system message to one player, after the mod's name; false when no
/// player with that id is online. Up to 8 messages per call.
tell: func(player: string, text: string) -> bool;
/// A system message to every player and the server's log, after the
/// mod's name. Counts toward the same 8 per call.
broadcast: func(text: string);
}
/// What the server calls in a server mod (manifest `api = "0.3"`,
/// experimental). Only the hooks `init` asks for are ever called. The player
/// in each event is as the server saw them when it happened.
interface server-events {
use players.{player};
record registration {
/// Commands players may type, without the slash. A name vanilla
/// already uses is refused (vanilla's command runs).
commands: list<string>,
/// Call `tick` every server tick (20 per second).
tick: bool,
/// Call `player-joined` and `player-left`.
joins: bool,
/// Call `chat` for each chat message a player sends.
chat: bool,
/// Call `block-broken` and `block-placed`.
blocks: bool,
}
record block-pos {
x: s32,
y: s32,
z: s32,
}
/// Once, after loading. An error keeps the mod off and is logged.
init: func() -> result<registration, string>;
/// Each server tick, with the server's tick counter.
tick: func(tick: u64);
/// A player joined the server; they are in `players.online` already.
player-joined: func(player: player);
/// A player left the server; they are no longer in `players.online`.
player-left: func(player: player);
/// A chat message a player sent, as plain text.
chat: func(player: player, text: string);
/// `/name args` typed by `player`: ok(some(text)) is their feedback,
/// err(text) their error in red.
command: func(player: player, name: string, args: string) -> result<option<string>, string>;
/// A player broke the block at `pos` in their dimension: `state` is what
/// was there, as `minecraft:oak_log[axis=y]`.
block-broken: func(player: player, pos: block-pos, state: string);
/// A player placed `state` at `pos` in their dimension.
block-placed: func(player: player, pos: block-pos, state: string);
}
/// A client mod: runs in the player's game.
world horizon-mod {
import log;
import game;
@@ -401,6 +500,16 @@ world horizon-mod {
export events;
}
/// A server mod (manifest `side = "server"`, `api = "0.3"`, experimental):
/// runs where the world is simulated, on the dedicated server, a LAN host
/// and single player's own server.
world horizon-server-mod {
import log;
import storage;
import players;
export server-events;
}
/// A mod with widgets (capability `ui`): the SDK builds every mod against
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
/// still load and simply hear nothing of their widgets.
+11 -2
View File
@@ -17,18 +17,27 @@ wasmtime = { version = "48.0.7", default-features = false, features = ["runtime"
# Turns a mod's core module into a component (cargo horizon-mod build, the tests).
wit-component = { version = "0.254", optional = true }
wit-parser = { version = "0.254", optional = true }
# The functions a mod imports, for its API level (src/api_level.rs): the game and the registry index.
wasmparser = { version = "0.254", optional = true }
# The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own).
sha2 = { version = "0.10", optional = true }
minisign-verify = { version = "0.3", optional = true }
# A shader pack's check (kind `shader`): the one the game runs before it loads a pack.
horizon-shader-api = { path = "../horizon-shader-api", optional = true }
[dev-dependencies]
# A component made for a newer Horizon, from the WIT alone (tests/sandbox.rs).
wit-component = { version = "0.254", features = ["dummy-module"] }
wit-parser = "0.254"
[features]
# Loads and runs mods (the game's `mods` feature); without it only manifests,
# content and packaging. Off by default so builds that don't run mods (the
# game's tests and probes) skip wasmtime and cranelift.
runtime = ["dep:wasmtime"]
runtime = ["dep:wasmtime", "dep:wasmparser"]
pack = ["dep:wit-component", "dep:wit-parser"]
# The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI.
index = ["dep:sha2", "dep:minisign-verify"]
index = ["dep:sha2", "dep:minisign-verify", "dep:horizon-shader-api", "dep:wasmparser"]
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
[[test]]
+189
View File
@@ -0,0 +1,189 @@
//! Mod API levels: which of the WIT contract's functions a Horizon has
//! (`docs/api/stability.md`). Every function the game offers mods has the
//! level it arrived at; a Horizon serves every level up to its [`LEVEL`].
//! A mod needs the level of the newest function it imports, so a mod that
//! calls a function this Horizon doesn't have is refused with "update
//! Horizon" instead of a linking error, and the registry lists each
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 1;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 32] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
("chat.show", 0),
("hud.set-panel", 0),
("hud.clear", 0),
("storage.get", 0),
("storage.set", 0),
("storage.delete", 0),
("command.run", 0),
("ui.open-screen", 0),
("ui.close-screen", 0),
("ui.set-settings", 0),
("input.claim-items", 0),
("input.register-key", 0),
("input.held", 0),
("input.poll", 0),
("input.claim-wheel", 0),
("input.mapping", 0),
("input.clicks-per-second", 0),
("render.set-hud", 0),
("render.gui-size", 0),
("render.shake", 0),
("render.set-corner", 0),
("render.clear-corner", 0),
("render.text-width", 0),
("render.frame-stats", 0),
("render.set-zoom", 0),
("render.set-minimap", 1),
("players.online", 1),
("players.tell", 1),
("players.broadcast", 1),
];
/// What the game's program carries so the launcher can read its level
/// without running it: `horizon-mod-api-level:0001;` (four digits). A
/// program from before the mark serves level 0.
pub const MARK: [u8; 27] = {
let mut mark = *b"horizon-mod-api-level:0000;";
let mut level = LEVEL;
let mut i = 25;
while level > 0 {
mark[i] = b'0' + (level % 10) as u8;
level /= 10;
i -= 1;
}
mark
};
/// The level a program's bytes carry in their [`MARK`], if any.
pub fn read_mark(program: &[u8]) -> Option<u32> {
let prefix = &MARK[..22];
program
.windows(MARK.len())
.filter(|w| w.starts_with(prefix) && w[26] == b';')
.find_map(|w| std::str::from_utf8(&w[22..26]).ok()?.parse().ok())
}
/// A function's level; None for one this Horizon doesn't have.
pub fn level_of(function: &str) -> Option<u32> {
FUNCTIONS
.iter()
.find(|(name, _)| *name == function)
.map(|&(_, level)| level)
}
/// The level a mod needs, from the functions it imports (as [`imports`]
/// names them): its newest function's. A function this Horizon doesn't
/// know comes from a newer one, so it needs more than [`LEVEL`]; that
/// function is returned with it, for the message.
pub fn needed<'a>(imports: impl IntoIterator<Item = &'a str>) -> (u32, Option<&'a str>) {
let mut needed = (0, None);
for name in imports {
match level_of(name) {
Some(level) if level > needed.0 => needed = (level, Some(name)),
Some(_) => {}
None => return (LEVEL + 1, Some(name)),
}
}
needed
}
/// Why this Horizon can't run a mod that needs `level`, or None when it can.
pub fn too_new(level: u32, function: Option<&str>) -> Option<String> {
(level > LEVEL).then(|| {
let uses = function.map_or(String::new(), |f| format!(" (it uses {f})"));
format!("made for a newer Horizon{uses}: update Horizon to run it")
})
}
/// The functions a mod's component imports from Horizon (`horizon:mods/*`),
/// as `interface.function`, in the component's order.
#[cfg(any(feature = "runtime", feature = "index"))]
pub fn imports(wasm: &[u8]) -> Result<Vec<String>, String> {
use wasmparser::{
Parser, Payload, Validator, WasmFeatures, component_types::ComponentEntityType,
};
let types = Validator::new_with_features(WasmFeatures::all())
.validate_all(wasm)
.map_err(|e| format!("not a component: {e}"))?;
let types = types.as_ref();
let mut out = Vec::new();
for payload in Parser::new(0).parse_all(wasm) {
let Payload::ComponentImportSection(section) = payload.map_err(|e| e.to_string())? else {
continue;
};
for import in section {
let name = import.map_err(|e| e.to_string())?.name.name;
// `horizon:mods/[email protected]` → `render`.
let Some(interface) = name.strip_prefix("horizon:mods/") else {
continue;
};
let interface = interface.split('@').next().unwrap_or(interface);
let Some(item) = types.component_item_for_import(name) else {
continue;
};
if let ComponentEntityType::Instance(id) = item.ty {
for (function, export) in &types[id].exports {
if matches!(export.ty, ComponentEntityType::Func(_)) {
out.push(format!("{interface}.{function}"));
}
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_are_listed_once_and_up_to_this_horizons() {
for (i, (name, level)) in FUNCTIONS.iter().enumerate() {
assert!(*level <= LEVEL, "{name}");
assert!(
FUNCTIONS[..i].iter().all(|(other, _)| other != name),
"{name} twice"
);
}
assert!(FUNCTIONS.iter().any(|&(_, level)| level == LEVEL));
}
#[test]
fn the_mark_carries_the_level() {
assert_eq!(read_mark(&MARK), Some(LEVEL));
let mut program = b"\0\x7fELF horizon-mod-api-level: horizon-mod-api-level:".to_vec();
program.extend_from_slice(&MARK);
program.extend_from_slice(b"\0rest");
assert_eq!(read_mark(&program), Some(LEVEL));
assert_eq!(read_mark(b"horizon-mod-api-level:12;"), None);
assert_eq!(read_mark(b"no mark"), None);
}
#[test]
fn a_mod_needs_its_newest_function() {
assert_eq!(needed(["log.write", "hud.set-panel"]), (0, None));
assert_eq!(
needed(["log.write", "render.set-minimap", "hud.clear"]),
(1, Some("render.set-minimap"))
);
assert_eq!(
needed(["render.set-minimap", "render.set-sky"]),
(LEVEL + 1, Some("render.set-sky"))
);
assert_eq!(too_new(LEVEL, None), None);
assert_eq!(
too_new(LEVEL + 1, Some("render.set-sky")).as_deref(),
Some("made for a newer Horizon (it uses render.set-sky): update Horizon to run it")
);
}
}
+66 -16
View File
@@ -47,8 +47,8 @@ pub enum Kind {
Modpack,
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
ResourcePack,
/// A Horizon shader pack (the native WGSL format, not Iris or OptiFine
/// packs). Listed by the schema; submissions open with its API.
/// A Horizon shader pack (`.hshader`, the native WGSL format of
/// docs/shaders.md; not the shaders/-folder packs, which stay on Modrinth).
Shader,
Other(String),
}
@@ -69,7 +69,8 @@ impl Kind {
match self {
Kind::Mod => Some("hmod"),
Kind::Modpack => Some("hpack"),
Kind::ResourcePack | Kind::Shader => Some("zip"),
Kind::ResourcePack => Some("zip"),
Kind::Shader => Some(horizon_shader_api::EXTENSION),
Kind::Other(_) => None,
}
}
@@ -137,6 +138,11 @@ pub struct Version {
pub side: Option<Side>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<Capability>,
/// The mod API level a mod's code needs ([`crate::api_level`]), read
/// from its file; none for content and other kinds, and in indexes
/// built before levels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_level: Option<u32>,
/// The Minecraft version it is for.
pub game: String,
/// A modpack's mods, as its manifest lists them.
@@ -316,6 +322,21 @@ impl Index {
Ok(())
}
/// A project's newest version for Minecraft `game` that wasn't taken
/// down and that a Horizon serving mod API `level` can run (an unknown
/// level counts as runnable: the game still checks when it starts it).
pub fn installable<'a>(
&self,
project: &'a Project,
game: &str,
level: u32,
) -> Option<&'a Version> {
project.versions.iter().find(|v| {
v.game == game
&& v.api_level.is_none_or(|l| l <= level)
&& self.revoked(&project.id, &v.file.sha256).is_none()
})
}
pub fn project(&self, id: &str) -> Option<&Project> {
self.projects.iter().find(|p| p.id == id)
}
@@ -448,7 +469,7 @@ pub fn check_file(
(None, None) => Trust::Unsigned,
_ => return Err("its signature and the project's key don't go together".into()),
};
if matches!(project.kind, Kind::Shader | Kind::Other(_)) {
if matches!(project.kind, Kind::Other(_)) {
return Err(format!(
"this Horizon can't install a {} yet",
project.kind.name()
@@ -471,6 +492,8 @@ pub struct Facts {
pub side: Option<Side>,
/// In the manifest's order.
pub capabilities: Vec<Capability>,
/// The mod API level a mod's code needs, from the functions it imports.
pub api_level: Option<u32>,
/// The Minecraft version, when the file names one (a modpack does).
pub game: Option<String>,
pub requires: Vec<Requirement>,
@@ -487,6 +510,7 @@ impl Facts {
|| self.api != version.api
|| self.side != version.side
|| caps != listed
|| version.api_level.is_some() && self.api_level != version.api_level
|| self.game.as_ref().is_some_and(|g| *g != version.game)
|| self.requires != version.requires;
if !differs {
@@ -502,6 +526,13 @@ impl Facts {
names(&self.capabilities)
),
Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST),
Kind::Shader => format!(
"its {} ({} {}, shader API {}) isn't what the index lists",
horizon_shader_api::MANIFEST,
self.id.as_deref().unwrap_or("?"),
self.version.as_deref().unwrap_or("?"),
self.api
),
_ => format!("its pack format {} isn't what the index lists", self.api),
})
}
@@ -509,14 +540,22 @@ impl Facts {
/// Reads and checks a file as its kind: a mod's manifest and content, a
/// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and
/// paths. Shader packs and kinds this build doesn't know are refused: their
/// checks come with their formats.
/// paths, a shader pack's `horizon_shader_api::check` (the game's own). Kinds
/// this build doesn't know are refused: their checks come with their formats.
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
match kind {
Kind::Mod => {
let m = Package::read(path)?.manifest;
let Package { manifest: m, wasm } = Package::read(path)?;
crate::Content::read(path, &m)?;
let api_level = match wasm {
Some(wasm) => {
let functions = crate::api_level::imports(&wasm)?;
Some(crate::api_level::needed(functions.iter().map(String::as_str)).0)
}
None => None,
};
Ok(Facts {
api_level,
capabilities: m.capabilities,
id: Some(m.id),
version: Some(m.version),
@@ -540,9 +579,23 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
api: check_resource_pack(path)?,
..Facts::default()
}),
Kind::Shader => Err(
"Horizon's own shader pack format isn't out yet: shader packs open when it is".into(),
),
Kind::Shader => {
let pack = horizon_shader_api::Pack::read(path)?;
if let Err(problems) = horizon_shader_api::check(&pack) {
let lines: Vec<String> = problems.iter().map(ToString::to_string).collect();
return Err(format!(
"the shader pack fails its check:\n{}",
lines.join("\n")
));
}
let m = pack.manifest;
Ok(Facts {
id: Some(m.id),
version: Some(m.version),
api: m.api,
..Facts::default()
})
}
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
}
}
@@ -591,14 +644,11 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
Ok(format)
}
/// The kinds the registry takes submissions for. Shader packs wait for
/// Horizon's own format; other kinds for theirs.
/// The kinds the registry takes submissions for; other kinds wait for their
/// formats.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()),
Kind::Shader => Err(
"shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(),
),
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
@@ -283,6 +283,7 @@ fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, St
api: facts.api,
side: facts.side,
capabilities: facts.capabilities,
api_level: facts.api_level,
game: v.game.clone(),
requires: facts.requires,
file: File {
@@ -1,9 +1,10 @@
//! A registry checkout holding the sample index's mod and modpack builds the
//! sample index (`tests/index/index-v1.json`) exactly.
//! A registry checkout holding the sample index's mod, modpack and shader pack
//! builds the sample index (`tests/index/index-v1.json`) exactly.
use super::*;
const HMOD: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53";
const HPACK: &str = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65";
const HSHADER: &str = "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02";
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index")
@@ -61,6 +62,23 @@ file = "cozy-1.0.0.hpack"
sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65"
"#;
const GLOW: &str = r#"id = "glow"
kind = "shader"
name = "Soft Glow"
summary = "A post-processing pass over the world."
authors = ["Horizon"]
owners = ["ludovic111"]
license = "MIT"
source = "https://github.com/ludovic111/horizon-mods"
[[versions]]
version = "1.0.0"
published = "2026-10-26"
game = "26.3"
file = "glow-1.0.0.hshader"
sha256 = "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02"
"#;
const REVOKED_TOML: &str = r#"[[revoked]]
id = "bad_mod"
sha256 = "1111111111111111111111111111111111111111111111111111111111111111"
@@ -82,9 +100,14 @@ impl Scratch {
let s = Scratch(dir);
s.write("mods/coords_hud.toml", &hud_entry());
s.write("mods/cozy.toml", COZY);
s.write("mods/glow.toml", GLOW);
s.write("revoked.toml", REVOKED_TOML);
s.write("icons/coords_hud.png", "png");
for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] {
for (name, hash) in [
("coords_hud-0.1.0.hmod", HMOD),
("cozy-1.0.0.hpack", HPACK),
("glow-1.0.0.hshader", HSHADER),
] {
std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap();
}
s
@@ -117,7 +140,7 @@ fn builds_the_sample_index() {
let s = Scratch::new("sample");
let built = s.build().unwrap();
let sample = sample();
assert_eq!(built.projects, sample.projects[..2]);
assert_eq!(built.projects, sample.projects[..3]);
assert_eq!(built.revoked, sample.revoked);
assert_eq!(built.generated, sample.generated);
// What it writes reads back.
@@ -154,7 +177,11 @@ fn lists_every_problem_of_a_registry() {
"{errors:?}"
);
assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}");
assert!(has("glow: shader packs open when"), "{errors:?}");
// A shader entry is taken now, and checked like the others.
assert!(
has("glow 1.0.0: the file isn't the one its sha256 names"),
"{errors:?}"
);
assert!(
has("other 0.1.0: the file's id is \"coords_hud\""),
"{errors:?}"
@@ -219,7 +246,7 @@ fn a_review_protects_published_files() {
#[test]
fn only_open_kinds_are_taken() {
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
assert!(open(&Kind::Shader).unwrap_err().contains("Iris"));
assert!(open(&Kind::Shader).is_ok());
assert!(open(&Kind::Other("world".into())).is_err());
let mut submit: Entry = toml::from_str(COZY).unwrap();
submit.id = "submit".into();
+92 -4
View File
@@ -1,7 +1,8 @@
//! The sample index (`tests/index/index-v1.json`, the one docs/api/mod-index.md
//! shows) and its files: `coords_hud-0.1.0.hmod`, signed with the throwaway
//! key `test.pub` by minisign 0.12 (its secret key was deleted), and
//! `cozy-1.0.0.hpack`, unsigned.
//! key `test.pub` by minisign 0.12 (its secret key was deleted),
//! `cozy-1.0.0.hpack`, unsigned, and `glow-1.0.0.hshader`, unsigned
//! (`horizon-shader new --template post`, then `horizon-shader pack`).
use super::*;
use std::path::PathBuf;
@@ -25,8 +26,8 @@ fn reads_the_sample_and_keeps_unknown_kinds() {
let index = sample();
assert_eq!(index.schema, SCHEMA);
let kinds: Vec<&str> = index.projects.iter().map(|p| p.kind.name()).collect();
assert_eq!(kinds, ["mod", "modpack", "world"]);
assert_eq!(index.projects[2].kind, Kind::Other("world".into()));
assert_eq!(kinds, ["mod", "modpack", "shader", "world"]);
assert_eq!(index.projects[3].kind, Kind::Other("world".into()));
let hud = index.project("coords_hud").unwrap();
assert_eq!(
hud.versions[0].capabilities,
@@ -44,6 +45,8 @@ fn files_pass_against_their_entries() {
assert_eq!(check(&index, "coords_hud", &hmod), Ok(Trust::Verified));
let hpack = fixtures().join("cozy-1.0.0.hpack");
assert_eq!(check(&index, "cozy", &hpack), Ok(Trust::Unsigned));
let hshader = fixtures().join("glow-1.0.0.hshader");
assert_eq!(check(&index, "glow", &hshader), Ok(Trust::Unsigned));
let world = index.project("skyblock_start").unwrap();
let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err();
assert!(e.contains("bytes"), "{e}");
@@ -73,6 +76,15 @@ fn a_changed_file_key_or_manifest_is_refused() {
let e = check(&more_caps, "coords_hud", &hmod).unwrap_err();
assert!(e.contains("isn't what the index lists"), "{e}");
let mut other_level = index.clone();
other_level.projects[0].versions[0].api_level = Some(1);
let e = check(&other_level, "coords_hud", &hmod).unwrap_err();
assert!(e.contains("isn't what the index lists"), "{e}");
// An index built before levels lists none: the file still passes.
let mut no_level = index.clone();
no_level.projects[0].versions[0].api_level = None;
assert_eq!(check(&no_level, "coords_hud", &hmod), Ok(Trust::Verified));
let mut revoked = index.clone();
revoked.revoked.push(Revoked {
id: "coords_hud".into(),
@@ -163,3 +175,79 @@ fn resource_packs_need_a_pack_mcmeta() {
assert!(e.contains("plain path"), "{e}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_newest_version_this_horizon_runs_is_installed() {
let mut index = sample();
let mut newer = index.projects[0].versions[0].clone();
newer.version = "0.2.0".into();
newer.file.sha256 = "2".repeat(64);
newer.api_level = Some(crate::api_level::LEVEL + 1);
index.projects[0].versions.insert(0, newer);
let p = index.project("coords_hud").unwrap();
let pick = |level| {
index
.installable(p, "26.3", level)
.map(|v| v.version.as_str())
};
assert_eq!(pick(crate::api_level::LEVEL), Some("0.1.0"));
assert_eq!(pick(crate::api_level::LEVEL + 1), Some("0.2.0"));
assert_eq!(index.installable(p, "26.4", crate::api_level::LEVEL), None);
// No level listed: runnable as far as the index knows.
index.projects[0].versions[0].api_level = None;
let p = index.project("coords_hud").unwrap();
let v = index.installable(p, "26.3", 0).unwrap();
assert_eq!(v.version, "0.2.0");
}
#[test]
fn shader_packs_pass_the_game_s_own_check() {
let manifest = "schema = 1\nid = \"glow\"\nname = \"Glow\"\nversion = \"1.0.0\"\napi = \"0.1\"\n\
[[post]]\nfile = \"post.wgsl\"\n";
let pack = |post: &str| {
let files = [
(horizon_shader_api::MANIFEST, manifest),
("post.wgsl", post),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
horizon_shader_api::Pack::from_files(files)
.unwrap()
.package()
.unwrap()
};
let dir = std::env::temp_dir().join(format!("horizon-mods-shader-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("glow-1.0.0.hshader");
std::fs::write(
&good,
pack("fn post(input: PostInput) -> vec4<f32> { return input.color; }\n"),
)
.unwrap();
let bad = dir.join("bad.hshader");
std::fs::write(
&bad,
pack("fn post(input: PostInput) -> vec4<f32> { return input.color }\n"),
)
.unwrap();
let facts = inspect(&Kind::Shader, &good);
let refused = inspect(&Kind::Shader, &bad).unwrap_err();
let not_a_pack = inspect(&Kind::Shader, &fixtures().join("cozy-1.0.0.hpack")).unwrap_err();
std::fs::remove_dir_all(&dir).unwrap();
let facts = facts.unwrap();
assert_eq!(
(
facts.id.as_deref(),
facts.version.as_deref(),
facts.api.as_str()
),
(Some("glow"), Some("1.0.0"), "0.1")
);
assert!(refused.contains("post.wgsl:1:"), "{refused}");
assert!(
not_a_pack.contains(horizon_shader_api::MANIFEST),
"{not_a_pack}"
);
assert_eq!(Kind::Shader.extension(), Some("hshader"));
assert!(open(&Kind::Shader).is_ok());
}
+1
View File
@@ -5,6 +5,7 @@
//!
//! Nothing here runs unless a mod is installed and turned on: an empty
//! [`Mods`] makes no engine and every hook returns at once.
pub mod api_level;
pub mod content;
#[cfg(feature = "index")]
pub mod index;
+52 -5
View File
@@ -16,7 +16,10 @@ pub const API: &str = "0.1";
/// The manifest `api` lines this build loads. 0.2 is 0.1 plus content:
/// `assets/` and `data/` in the `.hmod`, with blocks and items declared in
/// `data/<id>/horizon/` (docs/modpacks.md). Code still links API 0.1.
pub const APIS: [&str; 2] = [API, "0.2"];
/// 0.3 is a server mod (experimental): the `horizon-server-mod` world.
pub const APIS: [&str; 3] = [API, "0.2", SERVER_API];
/// The line of server mods, experimental: `side = "server"`.
pub const SERVER_API: &str = "0.3";
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
/// to those, docs/mods.md) and the game's own.
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
@@ -63,7 +66,7 @@ pub enum Side {
/// Runs in the player's game only (HUD, keys, chat commands).
#[default]
Client,
/// Runs where the world is simulated (rules, blocks); not run yet.
/// Runs where the world is simulated (rules, blocks): `api = "0.3"`.
Server,
/// Both, and the server tells clients which version it expects.
Both,
@@ -92,9 +95,11 @@ pub enum Capability {
/// The use button on items the mod claims, and its own keys
/// (interface `input`).
Input,
/// The players on the server and messages to them (server mods).
Players,
}
impl Capability {
pub const ALL: [Capability; 9] = [
pub const ALL: [Capability; 10] = [
Capability::GameRead,
Capability::Chat,
Capability::Hud,
@@ -104,7 +109,12 @@ impl Capability {
Capability::Ui,
Capability::Render,
Capability::Input,
Capability::Players,
];
/// Whether a server mod may have it: the others act on a local player.
pub fn on_server(self) -> bool {
matches!(self, Capability::Storage | Capability::Players)
}
/// The WIT interface it grants, in `horizon:mods`.
pub fn interface(self) -> &'static str {
match self {
@@ -117,6 +127,7 @@ impl Capability {
Capability::Ui => "ui",
Capability::Render => "render",
Capability::Input => "input",
Capability::Players => "players",
}
}
/// May change in any release: a mod uses it only with
@@ -141,6 +152,7 @@ impl Capability {
Capability::Input => {
"see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys"
}
Capability::Players => "see who is on the server and message them",
}
}
/// The capability a component import needs, `Ok(None)` for one every
@@ -175,6 +187,7 @@ impl fmt::Display for Capability {
Capability::Ui => "ui",
Capability::Render => "render",
Capability::Input => "input",
Capability::Players => "players",
};
f.write_str(name)
}
@@ -205,6 +218,20 @@ impl Manifest {
"{MANIFEST}: capability registries needs api = \"0.2\""
));
}
let server = m.side == Side::Server;
if server != (m.api == SERVER_API) {
return Err(format!(
"{MANIFEST}: a server mod is side = \"server\" with api = \"{SERVER_API}\", and only it"
));
}
if let Some(c) = m
.capabilities
.iter()
.find(|c| c.on_server() != server && **c != Capability::Storage)
{
let side = if server { "client" } else { "server" };
return Err(format!("{MANIFEST}: capability {c} is for {side} mods"));
}
if let Some(c) = m
.capabilities
.iter()
@@ -435,12 +462,32 @@ capabilities = ["game-read", "hud"]
fn refuses_bad_ids_and_apis() {
let bad_id = EXAMPLE.replace("coords_hud", "Coords HUD");
assert!(Manifest::parse(&bad_id).unwrap_err().contains("is not"));
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.3\"");
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.3"));
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.4\"");
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.4"));
let unknown = format!("{EXAMPLE}network = true\n");
assert!(Manifest::parse(&unknown).is_err());
}
#[test]
fn server_mods_are_api_0_3_with_server_capabilities() {
let server = |api: &str, side: &str, caps: &str| {
Manifest::parse(&format!(
"id = \"w\"\nname = \"W\"\nversion = \"1\"\napi = \"{api}\"\nside = \"{side}\"\ncapabilities = [{caps}]\n"
))
};
let m = server("0.3", "server", "\"players\", \"storage\"").unwrap();
let imports = ["horizon:mods/[email protected]", "horizon:mods/[email protected]"];
assert_eq!(m.check_imports(imports), Ok(()));
for (api, side) in [("0.3", "client"), ("0.3", "both"), ("0.1", "server")] {
let e = server(api, side, "").unwrap_err();
assert!(e.contains("a server mod is side"), "{e}");
}
let e = server("0.3", "server", "\"hud\"").unwrap_err();
assert!(e.contains("capability hud is for client mods"), "{e}");
let e = server("0.1", "client", "\"players\"").unwrap_err();
assert!(e.contains("capability players is for server mods"), "{e}");
}
#[test]
fn checks_extra_namespaces() {
let content = "id = \"t_and_t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\ncapabilities = [\"registries\"]\n";
+337 -81
View File
@@ -1,5 +1,9 @@
//! Loading and calling mods with wasmtime's component model.
use crate::manifest::{Manifest, Package, Side};
use server_bindings::{
HorizonServerMod, exports::horizon::mods::server_events::Registration as ServerRegistration,
horizon::mods::players,
};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -16,6 +20,17 @@ mod bindings {
world: "horizon-mod",
});
}
/// The server mods' world: `log` and `storage` are the client world's.
mod server_bindings {
wasmtime::component::bindgen!({
path: "../horizon-mod-sdk/wit",
world: "horizon-server-mod",
with: {
"horizon:mods/log": super::bindings::horizon::mods::log,
"horizon:mods/storage": super::bindings::horizon::mods::storage,
},
});
}
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
/// world, so mods built before it (no `ui-events` export) still instantiate
/// as `horizon-mod` and simply hear nothing of their widgets.
@@ -34,6 +49,12 @@ use bindings::{
exports::horizon::mods::events::Registration,
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
};
pub use server_bindings::{
exports::horizon::mods::server_events::BlockPos,
horizon::mods::players::{GameMode, Player as ServerPlayer},
};
type ClientEvents = bindings::exports::horizon::mods::events::Guest;
type ServerEvents = server_bindings::exports::horizon::mods::server_events::Guest;
/// The widget model a `ui` screen or settings page shows (WIT `interface
/// ui`), for the game to draw with its menus' own widgets.
pub use ui_bindings::horizon::mods::ui::{
@@ -52,8 +73,9 @@ use render_bindings::horizon::mods::input;
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
/// What capability `render` asks the game to draw (WIT `interface render`).
pub use render_bindings::horizon::mods::render::{
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Point as RenderPoint, QuadShape,
Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom,
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Minimap as ModMinimap,
Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, TextShape,
Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
@@ -100,6 +122,26 @@ pub struct Snapshot {
/// The HUD font's advance per character from U+0000
/// (`render.text-width`); empty: 6 each.
pub font_widths: Arc<[f32]>,
/// The players on the server, for server mods.
pub players: Vec<ServerPlayer>,
}
/// Which mods a game starts: the window's game the client mods, the game
/// simulating the world the server mods, single player in the frame both.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Runs {
Client,
Server,
Both,
}
impl Runs {
fn starts(self, side: Side) -> bool {
match self {
Runs::Both => true,
Runs::Client => side != Side::Server,
Runs::Server => side == Side::Server,
}
}
}
/// What mods asked the game to show since the last [`Mods::drain`].
@@ -114,6 +156,20 @@ pub enum Output {
Command { line: String },
/// The mod was turned off: it trapped, overran or failed to start.
Stopped { name: String, reason: String },
/// `players.tell`: a line after the mod's name, to the player with this id.
Tell {
name: String,
player: String,
text: String,
},
/// `players.broadcast`: a line after the mod's name, to every player.
Broadcast { name: String, text: String },
/// A server mod command's feedback, to the player who typed it.
Reply {
player: String,
text: String,
error: bool,
},
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
/// [`Mods::screen`].
OpenScreen { owner: usize },
@@ -135,6 +191,9 @@ pub enum Status {
/// Installed and turned off by the player.
Off,
Running,
/// Turned on, and run by another game: a server mod in the window while
/// the world runs on its server thread.
Elsewhere,
/// Could not load or start, or stopped while running.
Failed(String),
}
@@ -164,10 +223,36 @@ impl Entry {
struct Running {
store: Store<State>,
exports: HorizonMod,
/// The mod's `ui-events` export; none for a mod built before `ui`.
/// The mod's `ui-events` export; none for a mod built before `ui`, or a
/// server mod.
ui: Option<UiHost>,
hooks: Registration,
hooks: Hooks,
}
/// A running mod's exports and the hooks its `init` asked for.
enum Hooks {
Client(HorizonMod, Registration),
Server(HorizonServerMod, ServerRegistration),
}
impl Hooks {
fn client(&self) -> Option<&Registration> {
match self {
Hooks::Client(_, r) => Some(r),
Hooks::Server(..) => None,
}
}
fn server(&self) -> Option<&ServerRegistration> {
match self {
Hooks::Server(_, r) => Some(r),
Hooks::Client(..) => None,
}
}
fn commands(&self) -> &[String] {
match self {
Hooks::Client(_, r) => &r.commands,
Hooks::Server(_, r) => &r.commands,
}
}
}
/// What capability `ui` pushed since the hooks last ran.
@@ -196,6 +281,8 @@ struct State {
corner: Option<CornerPanel>,
/// Capability `render`'s zoom, when the mod zooms.
zoom: Option<ModZoom>,
/// Capability `render`'s minimap, when the mod shows one.
minimap: Option<ModMinimap>,
input: InputState,
/// Capability `storage`'s folder; made on first `set`.
data_dir: PathBuf,
@@ -218,13 +305,23 @@ pub struct Mods {
}
impl Mods {
/// Reads every mod at `paths` and starts those `enabled` says are on.
/// A command that `reserved` (vanilla's commands) or an earlier mod
/// already uses is refused.
/// Reads every mod at `paths` and starts those `enabled` says are on,
/// client and server mods alike. A command that `reserved` (vanilla's
/// commands) or an earlier mod already uses is refused.
pub fn load(
paths: &[PathBuf],
enabled: impl Fn(&str) -> bool,
reserved: impl Fn(&str) -> bool,
) -> Mods {
Mods::load_for(Runs::Both, paths, enabled, reserved)
}
/// [`Mods::load`], starting only the mods `runs` says this game runs; the
/// others that are on are [`Status::Elsewhere`].
pub fn load_for(
runs: Runs,
paths: &[PathBuf],
enabled: impl Fn(&str) -> bool,
reserved: impl Fn(&str) -> bool,
) -> Mods {
let mut mods = Mods::default();
for path in paths {
@@ -242,7 +339,9 @@ impl Mods {
Ok(package) => {
let on = enabled(&package.manifest.id);
entry.manifest = Some(package.manifest.clone());
if on {
if on && !runs.starts(package.manifest.side) {
entry.status = Status::Elsewhere;
} else if on {
// Content is read before the world loads; a mod whose
// content is refused stays off, with the reason.
let content = crate::Content::read(path, &package.manifest);
@@ -273,6 +372,8 @@ impl Mods {
let mut linker = Linker::new(&engine);
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
players::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
RenderHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
@@ -296,9 +397,6 @@ impl Mods {
let Some(wasm) = &package.wasm else {
return Ok(None);
};
if m.side == Side::Server {
return Err("server mods do not run yet".into());
}
// Two running mods with the same id would share one `data_dir`
// (capability `storage`): the first loaded keeps the id.
if self
@@ -308,11 +406,13 @@ impl Mods {
{
return Err(format!("another running mod already uses id {}", m.id));
}
let server = m.side == Side::Server;
let taken: Vec<String> = self
.entries
.iter()
.filter_map(|e| e.run.as_ref())
.flat_map(|r| r.hooks.commands.iter().cloned())
.filter(|r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().cloned())
.collect();
let snapshot = self.snapshot.clone();
let (engine, linker) = self.engine()?;
@@ -324,6 +424,12 @@ impl Mods {
.map(|(name, _)| name.to_string())
.collect();
m.check_imports(imports.iter().map(String::as_str))?;
// A function this Horizon doesn't have: say so, not wasmtime's linking error.
let functions = crate::api_level::imports(wasm)?;
let (level, function) = crate::api_level::needed(functions.iter().map(String::as_str));
if let Some(why) = crate::api_level::too_new(level, function) {
return Err(why);
}
let data_dir = path
.parent()
.unwrap_or(Path::new("."))
@@ -339,6 +445,7 @@ impl Mods {
shapes: Vec::new(),
corner: None,
zoom: None,
minimap: None,
input: InputState::default(),
data_dir,
storage_budget: STORAGE_WRITE_BUDGET,
@@ -353,15 +460,29 @@ impl Mods {
let instance = linker
.instantiate(&mut store, &component)
.map_err(|e| reason(&e))?;
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
// `ui-events` is a mod built since `ui`'s; older mods have no export
// and stay callable through `events` alone.
let ui = UiHost::new(&mut store, &instance).ok();
let mut hooks = exports
.horizon_mods_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
hooks.commands.retain(|c| {
let (mut hooks, ui) = if server {
let exports = HorizonServerMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
let r = exports
.horizon_mods_server_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
(Hooks::Server(exports, r), None)
} else {
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
// `ui-events` is a mod built since `ui`'s; older mods have no export
// and stay callable through `events` alone.
let ui = UiHost::new(&mut store, &instance).ok();
let r = exports
.horizon_mods_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
(Hooks::Client(exports, r), ui)
};
let commands = match &mut hooks {
Hooks::Client(_, r) => &mut r.commands,
Hooks::Server(_, r) => &mut r.commands,
};
commands.retain(|c| {
let free = !reserved(c) && !taken.contains(c);
if !free {
log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id);
@@ -369,18 +490,19 @@ impl Mods {
free
});
self.out.append(&mut store.data_mut().out);
Ok(Some(Running {
store,
exports,
ui,
hooks,
}))
Ok(Some(Running { store, ui, hooks }))
}
/// True when at least one mod runs: the game skips its mod work otherwise.
pub fn is_running(&self) -> bool {
self.entries.iter().any(|e| e.run.is_some())
}
/// True when a server mod runs: the server skips its mod work otherwise.
pub fn runs_server(&self) -> bool {
self.entries
.iter()
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.server().is_some()))
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
@@ -393,7 +515,7 @@ impl Mods {
fn each(
&mut self,
fuel: u64,
wants: impl Fn(&Registration) -> bool,
wants: impl Fn(&Hooks) -> bool,
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
) {
for i in 0..self.entries.len() {
@@ -406,6 +528,38 @@ impl Mods {
}
}
}
/// [`Mods::each`] over the client mods.
fn each_client(
&mut self,
fuel: u64,
wants: impl Fn(&Registration) -> bool,
mut f: impl FnMut(&ClientEvents, &mut Store<State>) -> wasmtime::Result<()>,
) {
self.each(
fuel,
|h| h.client().is_some_and(&wants),
|run| match &run.hooks {
Hooks::Client(m, _) => f(m.horizon_mods_events(), &mut run.store),
Hooks::Server(..) => Ok(()),
},
);
}
/// [`Mods::each`] over the server mods.
fn each_server(
&mut self,
fuel: u64,
wants: impl Fn(&ServerRegistration) -> bool,
mut f: impl FnMut(&ServerEvents, &mut Store<State>) -> wasmtime::Result<()>,
) {
self.each(
fuel,
|h| h.server().is_some_and(&wants),
|run| match &run.hooks {
Hooks::Server(m, _) => f(m.horizon_mods_server_events(), &mut run.store),
Hooks::Client(..) => Ok(()),
},
);
}
/// One call into mod `i`; a trap or an overrun turns the mod off.
fn call<R>(
@@ -453,62 +607,39 @@ impl Mods {
/// A game tick.
pub fn tick(&mut self, tick: u64) {
self.each(
FUEL_TICK,
|h| h.tick,
|run| {
run.exports
.horizon_mods_events()
.call_tick(&mut run.store, tick)
},
);
self.each_client(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
}
/// A rendered frame, `seconds` after the last.
pub fn frame(&mut self, seconds: f32) {
self.each(
FUEL_FRAME,
|h| h.frame,
|run| {
run.exports
.horizon_mods_events()
.call_frame(&mut run.store, seconds)
},
);
self.each_client(FUEL_FRAME, |h| h.frame, |m, s| m.call_frame(s, seconds));
}
/// A chat line the player received, as plain text.
pub fn chat(&mut self, text: &str) {
self.each(
FUEL_EVENT,
|h| h.chat,
|run| {
run.exports
.horizon_mods_events()
.call_chat(&mut run.store, text)
},
);
self.each_client(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, text));
}
/// Whether a running mod owns `/name`.
/// Whether a running client mod owns `/name`.
pub fn has_command(&self, name: &str) -> bool {
self.command_owner(name).is_some()
self.command_owner(false, name).is_some()
}
fn command_owner(&self, name: &str) -> Option<usize> {
fn command_owner(&self, server: bool, name: &str) -> Option<usize> {
self.entries.iter().position(|e| {
e.run
.as_ref()
.is_some_and(|r| r.hooks.commands.iter().any(|c| c == name))
e.run.as_ref().is_some_and(|r| {
r.hooks.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name)
})
})
}
/// Runs `line` (without its slash) if a mod owns its command; false
/// when none does.
/// Runs `line` (without its slash) if a client mod owns its command;
/// false when none does.
pub fn command(&mut self, line: &str) -> bool {
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
let Some(i) = self.command_owner(name) else {
let Some(i) = self.command_owner(false, name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |run| {
run.exports
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Client(m, _) => m
.horizon_mods_events()
.call_command(&mut run.store, name, args)
.call_command(&mut run.store, name, args),
Hooks::Server(..) => Ok(Ok(None)),
});
match result {
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
@@ -517,14 +648,89 @@ impl Mods {
}
true
}
/// The commands running mods own, for suggestions.
/// The commands running client mods own, for suggestions.
pub fn commands(&self) -> impl Iterator<Item = &str> {
self.owned_commands(false)
}
fn owned_commands(&self, server: bool) -> impl Iterator<Item = &str> {
self.entries
.iter()
.filter_map(|e| e.run.as_ref())
.flat_map(|r| r.hooks.commands.iter().map(String::as_str))
.filter(move |r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().map(String::as_str))
}
/// A server tick.
pub fn server_tick(&mut self, tick: u64) {
self.each_server(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
}
/// `player` joined the server (they are in the snapshot's players).
pub fn player_joined(&mut self, player: &ServerPlayer) {
self.each_server(
FUEL_EVENT,
|h| h.joins,
|m, s| m.call_player_joined(s, player),
);
}
/// `player` left the server (they are no longer in the snapshot's players).
pub fn player_left(&mut self, player: &ServerPlayer) {
self.each_server(
FUEL_EVENT,
|h| h.joins,
|m, s| m.call_player_left(s, player),
);
}
/// A chat message `player` sent, as plain text.
pub fn player_chat(&mut self, player: &ServerPlayer, text: &str) {
self.each_server(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, player, text));
}
/// `player` broke `state` at `pos`.
pub fn block_broken(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
self.each_server(
FUEL_EVENT,
|h| h.blocks,
|m, s| m.call_block_broken(s, player, pos, state),
);
}
/// `player` placed `state` at `pos`.
pub fn block_placed(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
self.each_server(
FUEL_EVENT,
|h| h.blocks,
|m, s| m.call_block_placed(s, player, pos, state),
);
}
/// The commands running server mods own, for the server's command tree.
pub fn server_commands(&self) -> impl Iterator<Item = &str> {
self.owned_commands(true)
}
/// Runs `line` (without its slash), typed by `player`, if a server mod
/// owns its command; its feedback is an [`Output::Reply`]. False when
/// none does.
pub fn server_command(&mut self, player: &ServerPlayer, line: &str) -> bool {
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
let Some(i) = self.command_owner(true, name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Server(m, _) => {
m.horizon_mods_server_events()
.call_command(&mut run.store, player, name, args)
}
Hooks::Client(..) => Ok(Ok(None)),
});
let reply = |text, error| Output::Reply {
player: player.id.clone(),
text,
error,
};
match result {
Some(Ok(Some(text))) => self.out.push(reply(text, false)),
Some(Err(text)) => self.out.push(reply(text, true)),
Some(Ok(None)) | None => {}
}
true
}
/// The running mods' HUD panels.
pub fn panels(&self) -> impl Iterator<Item = &Panel> {
self.entries
@@ -540,9 +746,11 @@ impl Mods {
/// Some running mod has a `frame` hook: the game refreshes the snapshot
/// every frame for it.
pub fn wants_frames(&self) -> bool {
self.entries
.iter()
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.frame))
self.entries.iter().any(|e| {
e.run
.as_ref()
.is_some_and(|r| r.hooks.client().is_some_and(|h| h.frame))
})
}
/// Some running mod claimed items (capability `input`).
pub fn claims_any(&self) -> bool {
@@ -684,6 +892,12 @@ impl Mods {
.filter_map(|e| e.run.as_ref()?.store.data().zoom)
.max_by(|a, b| a.factor.total_cmp(&b.factor))
}
/// The minimap the first running mod shows (capability `render`).
pub fn minimap(&self) -> Option<ModMinimap> {
self.entries
.iter()
.find_map(|e| e.run.as_ref()?.store.data().minimap)
}
fn inputs(&self) -> impl Iterator<Item = &InputState> {
self.entries
.iter()
@@ -771,7 +985,7 @@ impl Mods {
for i in 0..self.entries.len() {
let entry = &self.entries[i];
let now = modified(&entry.path);
let was_on = entry.status != Status::Off;
let was_on = !matches!(entry.status, Status::Off | Status::Elsewhere);
if now == entry.modified || !was_on {
continue;
}
@@ -880,20 +1094,51 @@ impl game::Host for State {
self.snapshot.clock
}
}
impl chat::Host for State {
fn show(&mut self, text: String) {
impl State {
/// Queues a chat line unless the call already sent [`CHAT_PER_CALL`].
fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool {
let shown = self
.out
.iter()
.filter(|o| matches!(o, Output::Chat { .. }))
.filter(|o| {
matches!(
o,
Output::Chat { .. } | Output::Tell { .. } | Output::Broadcast { .. }
)
})
.count();
if shown < CHAT_PER_CALL {
let name = self.name.clone();
self.out.push(Output::Chat {
name,
text: clip(text),
});
self.out.push(line(name));
}
shown < CHAT_PER_CALL
}
}
impl chat::Host for State {
fn show(&mut self, text: String) {
self.say(|name| Output::Chat {
name,
text: clip(text),
});
}
}
impl players::Host for State {
fn online(&mut self) -> Vec<ServerPlayer> {
self.snapshot.players.clone()
}
fn tell(&mut self, player: String, text: String) -> bool {
self.snapshot.players.iter().any(|p| p.id == player)
&& self.say(|name| Output::Tell {
name,
player,
text: clip(text),
})
}
fn broadcast(&mut self, text: String) {
self.say(|name| Output::Broadcast {
name,
text: clip(text),
});
}
}
/// A key a mod registered (capability `input`).
@@ -1013,6 +1258,9 @@ const SHAKE_SECONDS: f32 = 10.0;
/// or more, and turning never stops.
const ZOOM_MAX: f32 = 10.0;
const TURN_MIN: f32 = 0.05;
/// `set-minimap`'s side in GUI pixels and its blocks per map pixel.
const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
/// A mod's corner panel: shapes in a `width` × `height` box the game
/// places in `corner`, stacked with the other overlays.
@@ -1094,6 +1342,14 @@ impl render::Host for State {
},
});
}
fn set_minimap(&mut self, minimap: Option<ModMinimap>) {
let finite = |v: f32, lo: f32, hi: f32| if v.is_finite() { v.clamp(lo, hi) } else { lo };
self.minimap = minimap.map(|m| ModMinimap {
size: finite(m.size, MINIMAP_SIZE.0, MINIMAP_SIZE.1),
blocks_per_pixel: finite(m.blocks_per_pixel, MINIMAP_SCALE.0, MINIMAP_SCALE.1),
..m
});
}
fn frame_stats(&mut self) -> FrameRate {
let (fps, min, low) = self.snapshot.frame;
FrameRate { fps, min, low }
+238 -12
View File
@@ -2,10 +2,14 @@
//!
//! [`TestGame`] loads one `.hmod` from a scratch copy (its `storage` lives and
//! dies with the run) into the same sandbox the game uses, and plays the game's
//! part: the snapshot, ticks, frames, chat lines and commands. [`Script`] is a
//! part: the snapshot, ticks, frames, chat lines and commands, and for a
//! server mod the players joining, leaving, chatting and breaking or placing
//! blocks. [`Script`] is a
//! test written in TOML, `tests/<name>.toml` in the mod's folder: steps, each
//! saying what happens and what the mod must have shown by then.
use crate::{Clock, Corner, Mods, Output, Panel, Player, Snapshot, Status};
use crate::{
BlockPos, Clock, Corner, GameMode, Mods, Output, Panel, Player, ServerPlayer, Snapshot, Status,
};
use serde::Deserialize;
use std::{
path::{Path, PathBuf},
@@ -20,6 +24,10 @@ pub struct TestGame {
/// What the mod reads with `game-read`; ticks move the clock as the game does.
pub player: Option<Player>,
pub clock: Option<Clock>,
/// The players on the server, for a server mod, in join order.
pub players: Vec<ServerPlayer>,
/// Every name that joined, for their id.
names: Vec<String>,
tick: u64,
}
@@ -60,6 +68,8 @@ impl TestGame {
game_time: 0,
day_time: 0,
}),
players: Vec::new(),
names: Vec::new(),
tick: 0,
};
copy_mod(hmod, &copy)?;
@@ -87,11 +97,16 @@ impl TestGame {
self.mods.set_snapshot(Snapshot {
player: self.player.clone(),
clock: self.clock,
players: self.players.clone(),
// The GUI of a 1280 x 720 window at scale 3, as the trial run's.
gui_size: (427.0, 240.0),
..Snapshot::default()
});
}
/// Whether the mod is a server mod.
pub fn server(&self) -> bool {
self.mods.runs_server()
}
/// `n` game ticks; each moves the clock one tick on, as the game does.
pub fn tick(&mut self, n: u64) {
@@ -103,6 +118,7 @@ impl TestGame {
}
self.snapshot();
self.mods.tick(self.tick);
self.mods.server_tick(self.tick);
}
}
/// `n` frames, 1/60 s apart.
@@ -124,13 +140,76 @@ impl TestGame {
self.mods.command(line)
}
/// A player named `name` joins the server, standing where a script starts
/// in survival. Their id is `00000000-0000-4000-8000-000000000001` for
/// the first name that joined, `...0002` for the second, and so on.
pub fn join(&mut self, name: &str) -> ServerPlayer {
let n = match self.names.iter().position(|n| n == name) {
Some(i) => i + 1,
None => {
self.names.push(name.to_owned());
self.names.len()
}
};
let p = default_player();
let player = ServerPlayer {
id: format!("00000000-0000-4000-8000-{n:012x}"),
name: name.to_owned(),
x: p.x,
y: p.y,
z: p.z,
dimension: p.dimension,
game_mode: GameMode::Survival,
health: p.health,
};
self.players.push(player.clone());
self.snapshot();
self.mods.player_joined(&player);
player
}
/// The player named `name` leaves; false when no such player is on.
pub fn leave(&mut self, name: &str) -> bool {
let Some(i) = self.players.iter().position(|p| p.name == name) else {
return false;
};
let player = self.players.remove(i);
self.snapshot();
self.mods.player_left(&player);
true
}
/// A chat message `player` sends, for a server mod.
pub fn player_chat(&mut self, player: &ServerPlayer, line: &str) {
self.snapshot();
self.mods.player_chat(player, line);
}
/// `/line` typed by `player`, for a server mod; false when the mod owns
/// no such command.
pub fn player_command(&mut self, player: &ServerPlayer, line: &str) -> bool {
self.snapshot();
self.mods.server_command(player, line)
}
/// `player` breaks (or places) `state` at `pos`.
pub fn block(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str, placed: bool) {
self.snapshot();
if placed {
self.mods.block_placed(player, pos, state);
} else {
self.mods.block_broken(player, pos, state);
}
}
/// The scratch copy the mod runs from; its `storage` is beside it.
pub fn hmod(&self) -> &Path {
&self.hmod
}
/// The commands the mod registered.
pub fn commands(&self) -> Vec<String> {
self.mods.commands().map(str::to_owned).collect()
let server = self.mods.server_commands();
self.mods
.commands()
.chain(server)
.map(str::to_owned)
.collect()
}
pub fn panel(&self) -> Option<&Panel> {
self.mods.panels().next()
@@ -166,7 +245,7 @@ fn copy_mod(from: &Path, to: &Path) -> Result<(), String> {
Ok(())
}
/// A test in TOML: where the player starts, then steps run in order.
/// A test in TOML: where a client mod's player starts, then steps run in order.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Script {
@@ -178,9 +257,11 @@ pub struct Script {
pub steps: Vec<Step>,
}
/// Fields of the player to change; the others keep their values.
/// Fields of the player to change; the others keep their values. A server
/// mod's players have no `yaw`, `pitch` or `food`, and a client mod's player
/// no `game-mode`.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PlayerSet {
x: Option<f64>,
y: Option<f64>,
@@ -190,6 +271,37 @@ pub struct PlayerSet {
health: Option<f32>,
food: Option<u32>,
dimension: Option<String>,
game_mode: Option<Mode>,
}
/// `players.game-mode` as a script spells it.
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Mode {
Survival,
Creative,
Adventure,
Spectator,
}
impl From<Mode> for GameMode {
fn from(m: Mode) -> GameMode {
match m {
Mode::Survival => GameMode::Survival,
Mode::Creative => GameMode::Creative,
Mode::Adventure => GameMode::Adventure,
Mode::Spectator => GameMode::Spectator,
}
}
}
/// A block a player breaks or places.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BlockSet {
x: i32,
y: i32,
z: i32,
state: String,
}
#[derive(Debug, Default, Deserialize)]
@@ -200,8 +312,11 @@ pub struct ClockSet {
}
/// One step. Its parts happen in this order: `reload`, `title-screen`,
/// `player`, `clock`, `ticks`, `frames`, `chat`, `command`; then `expect` is
/// checked against what the mod showed during the step.
/// `join`, `player`, `clock`, `ticks`, `frames`, `chat`, `command`, `break`,
/// `place`, `leave`; then `expect` is checked against what the mod showed
/// during the step. For a server mod, `player`, `chat`, `command`, `break`
/// and `place` are the doing of the player named by `as`, or of the first
/// player on.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Step {
@@ -218,6 +333,14 @@ pub struct Step {
frames: u64,
chat: Option<String>,
command: Option<String>,
/// Server mods: a player joins, leaves, breaks or places a block.
join: Option<String>,
leave: Option<String>,
#[serde(rename = "as")]
actor: Option<String>,
#[serde(rename = "break")]
broken: Option<BlockSet>,
place: Option<BlockSet>,
#[serde(default)]
expect: Expect,
}
@@ -233,6 +356,10 @@ pub struct Expect {
/// The command's feedback, and its red error.
feedback: Option<String>,
error: Option<String>,
/// Server mods: every `players.tell` as `"Name: text"`, and every
/// `players.broadcast`, in order.
told: Option<Vec<String>>,
broadcast: Option<Vec<String>>,
/// The panel's lines (`[]`: no panel) and its corner.
panel: Option<Vec<String>>,
corner: Option<String>,
@@ -276,6 +403,17 @@ impl PlayerSet {
p.dimension.clone_from(d);
}
}
fn apply_server(&self, p: &mut ServerPlayer) {
let set = |to: &mut f64, v: Option<f64>| *to = v.unwrap_or(*to);
set(&mut p.x, self.x);
set(&mut p.y, self.y);
set(&mut p.z, self.z);
p.health = self.health.unwrap_or(p.health);
p.game_mode = self.game_mode.map_or(p.game_mode, GameMode::from);
if let Some(d) = &self.dimension {
p.dimension.clone_from(d);
}
}
}
impl ClockSet {
@@ -304,6 +442,17 @@ impl Step {
if let Some(line) = &self.command {
parts.push(format!("/{line}"));
}
if let Some(name) = &self.join {
parts.insert(0, format!("{name} joins"));
}
for (what, b) in [("breaks", &self.broken), ("places", &self.place)] {
if let Some(b) = b {
parts.push(format!("{what} {} at {} {} {}", b.state, b.x, b.y, b.z));
}
}
if let Some(name) = &self.leave {
parts.push(format!("{name} leaves"));
}
if parts.is_empty() {
parts.push("checks".into());
}
@@ -318,8 +467,35 @@ impl Step {
game.player = None;
game.clock = None;
}
if let Some(name) = &self.join {
game.join(name);
}
let server = game.server();
let actor = |game: &TestGame| match &self.actor {
Some(name) => game
.players
.iter()
.position(|p| p.name == *name)
.ok_or_else(|| format!("{name} is not on the server")),
None => (!game.players.is_empty())
.then_some(0)
.ok_or_else(|| "no player is on the server: add join = \"Name\"".to_string()),
};
let acts = self.player.is_some()
|| self.chat.is_some()
|| self.command.is_some()
|| self.broken.is_some()
|| self.place.is_some();
let actor = if server && acts {
Some(actor(game)?)
} else {
None
};
if let Some(set) = &self.player {
set.apply(game.player.get_or_insert_with(default_player));
match actor {
Some(i) => set.apply_server(&mut game.players[i]),
None => set.apply(game.player.get_or_insert_with(default_player)),
}
}
if let Some(set) = &self.clock {
set.apply(game.clock.get_or_insert(Clock {
@@ -329,10 +505,32 @@ impl Step {
}
game.tick(self.ticks);
game.frames(self.frames);
let who = actor.map(|i| game.players[i].clone());
if let Some(line) = &self.chat {
game.chat(line);
match &who {
Some(p) => game.player_chat(p, line),
None => game.chat(line),
}
}
let owned = self.command.as_ref().map(|line| match &who {
Some(p) => game.player_command(p, line),
None => game.command(line),
});
for (b, placed) in [(&self.broken, false), (&self.place, true)] {
if let (Some(b), Some(p)) = (b, &who) {
let pos = BlockPos {
x: b.x,
y: b.y,
z: b.z,
};
game.block(p, pos, &b.state, placed);
}
}
if let Some(name) = &self.leave
&& !game.leave(name)
{
return Err(format!("{name} is not on the server"));
}
let owned = self.command.as_ref().map(|line| game.command(line));
let out = game.drain();
let stopped = out.iter().find_map(|o| match o {
Output::Stopped { reason, .. } => Some(reason.as_str()),
@@ -372,10 +570,38 @@ impl Step {
}
let feedback = |error: bool| {
out.iter().rev().find_map(|o| match o {
Output::Feedback { text, error: e } if *e == error => Some(text.clone()),
Output::Feedback { text, error: e } | Output::Reply { text, error: e, .. }
if *e == error =>
{
Some(text.clone())
}
_ => None,
})
};
if let Some(want) = &e.told {
let name = |id: &str| {
let p = game.players.iter().find(|p| p.id == id);
p.map_or(id.to_owned(), |p| p.name.clone())
};
let got: Vec<String> = out
.iter()
.filter_map(|o| match o {
Output::Tell { player, text, .. } => Some(format!("{}: {text}", name(player))),
_ => None,
})
.collect();
same("told", want, &got)?;
}
if let Some(want) = &e.broadcast {
let got: Vec<String> = out
.iter()
.filter_map(|o| match o {
Output::Broadcast { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
same("broadcast", want, &got)?;
}
if let Some(want) = &e.feedback {
same("feedback", &Some(want.clone()), &feedback(false))?;
}
@@ -302,3 +302,38 @@ fn every_capability_with_an_interface_is_in_the_contract() {
}
}
}
/// Every function the game offers (an interface some world imports and none
/// exports) has an API level, and the levels list nothing else.
#[test]
fn every_function_has_an_api_level() {
let mut resolve = Resolve::default();
let package = resolve.push_file(root().join(WIT)).unwrap();
let package = &resolve.packages[package];
let exported: Vec<_> = package
.worlds
.values()
.flat_map(|&w| resolve.worlds[w].exports.values())
.filter_map(|item| match item {
WorldItem::Interface { id, .. } => Some(*id),
_ => None,
})
.collect();
let mut offered = Vec::new();
for (name, &id) in &package.interfaces {
if !exported.contains(&id) {
for function in resolve.interfaces[id].functions.keys() {
offered.push(format!("{name}.{function}"));
}
}
}
for f in &offered {
assert!(
horizon_mods::api_level::level_of(f).is_some(),
"{f} has no level in crates/horizon-mods/src/api_level.rs: add it at the next level"
);
}
for (f, _) in horizon_mods::api_level::FUNCTIONS {
assert!(offered.iter().any(|o| o == f), "{f} isn't in the WIT");
}
}
Binary file not shown.
@@ -29,6 +29,7 @@
"hud",
"chat"
],
"api_level": 0,
"game": "26.3",
"file": {
"name": "coords_hud-0.1.0.hmod",
@@ -75,6 +76,34 @@
}
]
},
{
"id": "glow",
"kind": "shader",
"name": "Soft Glow",
"summary": "A post-processing pass over the world.",
"authors": [
"Horizon"
],
"owners": [
"ludovic111"
],
"license": "MIT",
"source": "https://github.com/ludovic111/horizon-mods",
"versions": [
{
"version": "1.0.0",
"published": "2026-10-26",
"api": "0.1",
"game": "26.3",
"file": {
"name": "glow-1.0.0.hshader",
"url": "https://github.com/ludovic111/horizon-mods/releases/download/glow-1.0.0/glow-1.0.0.hshader",
"size": 689,
"sha256": "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02"
}
}
]
},
{
"id": "skyblock_start",
"kind": "world",
+181 -13
View File
@@ -2,7 +2,8 @@
//! examples and a guest
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
use horizon_mods::{
Clock, Corner, HeldItem, Mods, Output, Package, Player, Shape, Snapshot, Status,
BlockPos, Clock, Corner, GameMode, HeldItem, Mods, Output, Package, Player, Runs, ServerPlayer,
Shape, Snapshot, Status,
manifest::componentize,
testing::{Script, TestGame},
};
@@ -44,6 +45,8 @@ fn mods_dir() -> &'static Path {
"-p",
"predator-missile",
"-p",
"welcome",
"-p",
"horizon-mods-test-guest",
"-p",
"horizon-mods-test-ui-guest",
@@ -66,6 +69,7 @@ fn mods_dir() -> &'static Path {
let mods = [
("coords_hud", example("coords-hud")),
("predator_missile", example("predator-missile")),
("welcome", example("welcome")),
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
];
@@ -193,6 +197,65 @@ fn undeclared_capabilities_are_refused() {
assert_eq!(mods.entries()[0].status, refused);
}
/// A mod calling a function this Horizon doesn't have is refused with
/// "update Horizon", not a linking error; the test mods' levels are read
/// from the functions they import.
#[test]
fn a_mod_for_a_newer_horizon_is_refused() {
use horizon_mods::api_level::{LEVEL, imports, needed};
use wit_parser::{ManglingAndAbi, Resolve};
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit"))
.unwrap()
.replace(
" set-minimap: func(minimap: option<minimap>);",
" set-minimap: func(minimap: option<minimap>);\n set-sky: func(on: bool);",
);
let mut resolve = Resolve::default();
let package = resolve.push_str("horizon.wit", &wit).unwrap();
let world = resolve
.select_world(&[package], Some("horizon-ui-mod"))
.unwrap();
let mut module = wit_component::dummy_module(&resolve, world, ManglingAndAbi::Standard32);
wit_component::embed_component_metadata(
&mut module,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
let wasm = wit_component::ComponentEncoder::default()
.module(&module)
.unwrap()
.encode()
.unwrap();
let functions = imports(&wasm).unwrap();
assert!(functions.iter().any(|f| f == "render.set-sky"));
let dir = mods_dir().join("too_new");
std::fs::create_dir_all(&dir).unwrap();
let manifest = r#"
id = "too_new"
name = "Too New"
version = "0.1.0"
api = "0.1"
capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"]
experimental = ["ui", "render", "input"]
"#;
std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap();
std::fs::write(dir.join("mod.wasm"), wasm).unwrap();
let mods = Mods::load(&[dir], |_| true, |_| false);
let refused = "made for a newer Horizon (it uses render.set-sky): update Horizon to run it";
assert_eq!(mods.entries()[0].status, Status::Failed(refused.into()));
let level = |file: &str| {
let wasm = Package::read(&mods_dir().join(file)).unwrap().wasm.unwrap();
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
};
assert_eq!(level("coords_hud.hmod"), 0);
// The UI guest shows a minimap (`render.set-minimap`).
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
}
#[test]
fn vanilla_commands_win() {
let mods = Mods::load(
@@ -633,6 +696,32 @@ fn render_zoom_is_clamped_and_the_strongest_counts() {
assert_eq!(zoom(&mut mods, "x 0 1"), None);
}
#[test]
fn render_minimap_is_clamped_and_hidden() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
let map = |mods: &mut Mods, args: &str| {
assert!(mods.command(&format!("minimap {args}")));
mods.minimap()
.map(|m| (m.corner, m.size, m.blocks_per_pixel, m.rotate, m.round))
};
assert!(mods.minimap().is_none());
let corner = horizon_mods::RenderCorner::BottomLeft;
assert_eq!(
map(&mut mods, "96 2 1 0"),
Some((corner, 96.0, 2.0, true, false))
);
// The side and the scale are clamped; NaN takes the smallest.
assert_eq!(
map(&mut mods, "999 0.1 0 1"),
Some((corner, 256.0, 0.5, false, true))
);
assert_eq!(
map(&mut mods, "x 9 0 0"),
Some((corner, 16.0, 4.0, false, false))
);
assert_eq!(map(&mut mods, "off"), None);
}
#[test]
fn corner_panels_text_width_and_readings() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
@@ -705,22 +794,23 @@ capabilities = ["registries"]
assert!(!mods.is_running());
}
/// `cargo horizon-mod test` on the example: its own `tests/*.toml` pass.
/// `cargo horizon-mod test` on the examples: their own `tests/*.toml` pass.
#[test]
fn the_examples_test_scripts_pass() {
let tests =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../horizon-mod-sdk/examples/coords-hud/tests");
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../horizon-mod-sdk/examples");
let mut ran = 0;
for entry in std::fs::read_dir(tests).unwrap() {
let path = entry.unwrap().path();
let script = Script::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let mut game = TestGame::load(&mods_dir().join("coords_hud.hmod")).unwrap();
script
.run(&mut game)
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
ran += 1;
for (example, hmod) in [("coords-hud", "coords_hud"), ("welcome", "welcome")] {
for entry in std::fs::read_dir(examples.join(example).join("tests")).unwrap() {
let path = entry.unwrap().path();
let script = Script::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let mut game = TestGame::load(&mods_dir().join(format!("{hmod}.hmod"))).unwrap();
script
.run(&mut game)
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
ran += 1;
}
}
assert_eq!(ran, 2);
assert_eq!(ran, 4);
}
#[test]
@@ -744,6 +834,13 @@ fn a_test_script_says_which_step_failed_and_why() {
Script::parse("[[step]]\ntick = 1").is_err(),
"a typo is an error"
);
let mut game = TestGame::load(&mods_dir().join("welcome.hmod")).unwrap();
let script = "[[step]]\njoin = \"Alex\"\n\n[[step]]\nas = \"Steve\"\ncommand = \"mined\"";
assert_eq!(
Script::parse(script).unwrap().run(&mut game).unwrap_err(),
"step 2 (/mined): Steve is not on the server"
);
}
#[test]
@@ -781,3 +878,74 @@ fn a_test_script_reloads_and_expects_a_stop() {
"the scratch copy and its storage are removed"
);
}
fn alex() -> ServerPlayer {
ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Alex".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:the_nether".into(),
game_mode: GameMode::Survival,
health: 20.0,
}
}
#[test]
fn a_server_mod_runs_only_where_the_world_does() {
// A fresh folder: what the mod stored in an earlier run is beside it.
let dir = mods_dir().join("server");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("welcome.hmod");
std::fs::copy(mods_dir().join("welcome.hmod"), &path).unwrap();
let paths = std::slice::from_ref(&path);
let window = Mods::load_for(Runs::Client, paths, |_| true, |_| false);
assert_eq!(window.entries()[0].status, Status::Elsewhere);
assert!(!window.is_running());
let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give");
assert!(mods.runs_server());
assert_eq!(
mods.server_commands().collect::<Vec<_>>(),
["online", "mined"]
);
assert_eq!(mods.commands().count(), 0, "not the window's chat commands");
let alex = alex();
mods.set_snapshot(Snapshot {
players: vec![alex.clone()],
..Snapshot::default()
});
mods.player_joined(&alex);
let greeted = [
Output::Tell {
name: "Welcome".into(),
player: alex.id.clone(),
text: "Welcome, Alex! 0 other player(s) online.".into(),
},
Output::Broadcast {
name: "Welcome".into(),
text: "Alex is here for the first time".into(),
},
];
assert_eq!(mods.drain(), greeted);
let stone = BlockPos { x: 1, y: 63, z: 0 };
mods.block_broken(&alex, stone, "minecraft:stone");
assert!(mods.server_command(&alex, "mined"));
assert!(mods.server_command(&alex, "online"));
let reply = |text: &str| Output::Reply {
player: alex.id.clone(),
text: text.into(),
error: false,
};
assert_eq!(
mods.drain(),
[reply("You broke 1 block(s)"), reply("Alex (the_nether)")]
);
assert!(!mods.server_command(&alex, "where"));
assert!(
!mods.command("online"),
"a server command isn't the window's"
);
}
@@ -96,6 +96,7 @@ impl Mod for Guest {
"input_held",
"input_wheel",
"zoom",
"minimap",
"corner",
"readings",
]
@@ -198,6 +199,24 @@ impl Mod for Guest {
turn,
});
}
// `minimap off`, or `minimap SIZE BLOCKS-PER-PIXEL ROTATE ROUND`.
"minimap" => {
let words: Vec<&str> = args.split(' ').collect();
let number = |i: usize| {
words
.get(i)
.and_then(|w| w.parse().ok())
.unwrap_or(f32::NAN)
};
let map = (args != "off").then(|| render::Minimap {
corner: render::Corner::BottomLeft,
size: number(0),
blocks_per_pixel: number(1),
rotate: words.get(2) == Some(&"1"),
round: words.get(3) == Some(&"1"),
});
render::set_minimap(map);
}
// Clamped to (1, 10); the two after it shake nothing.
"render_shake" => {
render::shake(2.0, 99.0);
+3 -3
View File
@@ -828,9 +828,9 @@ pub fn carry(
// Additions to other sets and pools, as files the game merges.
let modifier = serde_json::from_slice::<Value>(&bytes).ok();
let types = &mut report.worldgen_types;
if let Some((kind, mut added)) = modifier.and_then(|v| {
crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types)
}) {
if let Some((kind, mut added)) =
modifier.and_then(|v| crate::lithostitched::modifier(&v, loaded, types))
{
crate::processors::carry(
kind,
&mut added,
@@ -157,6 +157,7 @@ mod tests {
}
#[test]
#[ignore = "modular compat is crate::lithostitched's now (its tests)"]
fn modular_compat_runs_its_list_only_with_its_mod() {
let mut data = ProcessorData::default();
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
@@ -1,503 +0,0 @@
//! Lithostitched: its jigsaw structure, its worldgen modifiers and its
//! processors.
use super::ctov;
use crate::processors::{ProcessorData, carry_all, processors, referenced};
use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the
/// start rules it shares with the other libraries' jigsaws (`worldgen`'s
/// `start_checks`). `None` for any other file.
pub(crate) fn upgrade(
kind: &str,
v: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> Option<bool> {
if kind != "structure" || v["type"] != "lithostitched:jigsaw" {
return None;
}
start_checks(v, "lowest", lost, false);
if v.as_object_mut()
.and_then(|o| o.remove(ENHANCED_TERRAIN))
.is_some()
{
*lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1;
}
v["type"] = "minecraft:jigsaw".into();
Some(true)
}
/// A lithostitched worldgen modifier
/// (`data/<ns>/lithostitched/worldgen_modifier/`) that adds to structure
/// sets or template pools, as a file the game merges into them (the key
/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The
/// modifier's conditions are checked against `loaded`, the mods ported
/// along: a compat addition for a mod that is not would place that mod's
/// blocks, which the game does not have. Other modifier types are left out.
/// Both are counted in `report`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let t = v["type"].as_str().unwrap_or_default();
let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1;
let (kind, targets, from, to) = match t {
"lithostitched:add_structure_set_entries" => {
("structure_set", "structure_sets", "entries", "structures")
}
"lithostitched:add_template_pool_elements" => {
("template_pool", "template_pools", "elements", "elements")
}
_ => {
left(t);
return None;
}
};
if !conditions_hold(v, loaded) {
left("conditions");
return None;
}
let targets: Option<Vec<&str>> = match &v[targets] {
Value::String(s) => Some(vec![s.as_str()]),
Value::Array(a) => a.iter().map(Value::as_str).collect(),
_ => None,
};
let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#')))
else {
left("target");
return None;
};
let entries = v[from].as_array().cloned().unwrap_or_default();
for target in &targets {
*report.added_to.entry((*target).to_owned()).or_default() += entries.len();
}
Some((
kind,
serde_json::json!({"horizon:add_to": targets, to: entries}),
))
}
/// Whether a modifier's load conditions hold: lithostitched's `predicate`,
/// and the loader's conditions (NeoForge's, else Fabric's). A condition
/// type not known here does not hold.
fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |list: &Value, test: &dyn Fn(&Value) -> bool| {
list.as_array().is_some_and(|a| a.iter().all(test))
};
fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| predicate(q, loaded);
let list = &p["predicates"];
match p["type"]
.as_str()
.map(|t| t.trim_start_matches("lithostitched:"))
{
Some("true") => true,
Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded),
Some("not") => !predicate(&p["predicate"], loaded),
Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)),
Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| neoforge(q, loaded);
match c["type"].as_str() {
Some("neoforge:true") => true,
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:not") => !neoforge(&c["value"], loaded),
Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let test = |q: &Value| fabric(q, loaded);
let mods = || {
c["values"]
.as_array()
.into_iter()
.flatten()
.map(Value::as_str)
};
match c["condition"].as_str() {
Some("fabric:true") => true,
Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)),
Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)),
Some("fabric:not") => !fabric(&c["value"], loaded),
Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)),
Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)),
_ => false,
}
}
let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded));
let loader = match (
v.get("neoforge:conditions"),
v.get("fabric:load_conditions"),
) {
(Some(c), _) => all(c, &|c| neoforge(c, loaded)),
// One condition or a list of them.
(None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)),
(None, Some(c)) => fabric(c, loaded),
(None, None) => true,
};
litho && loader
}
/// The data form of one of Lithostitched's processors: the processors to put
/// in its place, or `None` when it has none (or is not Lithostitched's).
pub(crate) fn processor(
kind: &str,
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Vec<Value>> {
match kind {
"lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]),
"lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]),
"lithostitched:block_swap" => {
// The same map; the game's swap keeps the properties both have.
p["blocks"].as_object()?;
Some(vec![
json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}),
])
}
"lithostitched:set_block" => {
// A simple or weighted state provider, the block entity left as
// it is.
let provider = p["state_provider"]["type"].as_str()?;
let modifier = p["block_entity_modifier"]["type"].as_str();
let plain =
modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough");
if !plain
|| !matches!(
provider,
"minecraft:simple_state_provider" | "minecraft:weighted_state_provider"
)
{
return None;
}
let mut out = p.clone();
out["processor_type"] = "horizon:set_block".into();
if let Some(o) = out.as_object_mut() {
o.remove("block_entity_modifier");
}
Some(vec![out])
}
_ => None,
}
}
/// The references (Lithostitched's, and CTOV's compat processors) in a
/// processor list written in; whether any was.
pub(crate) fn expand_references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> (Vec<Value>, bool) {
references(list, data, carried, 0)
}
/// A processor list inside a carried processor: an id stays (the game reads
/// that file, carried on its own); an inline one is carried here.
fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap<String, usize>) -> Value {
let mut list = list.clone();
if let Some(ps) = processors(&mut list) {
*ps = carry_all(std::mem::take(ps), data, carried).0;
}
list
}
/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted
/// lists, or its set of lists at weight 1. A tag is not carried.
fn apply_random(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
let lists = &p["processor_lists"];
let weighted = lists.as_array().is_some_and(|a| {
a.iter()
.all(|e| e.get("weight").is_some() && e.get("data").is_some())
});
let entries: Vec<(Value, Value)> = if weighted {
lists
.as_array()?
.iter()
.map(|e| (e["weight"].clone(), e["data"].clone()))
.collect()
} else {
match lists {
Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())],
Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => {
vec![(1.into(), lists.clone())]
}
Value::Array(a)
if a.iter()
.all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) =>
{
a.iter().map(|x| (1.into(), x.clone())).collect()
}
Value::Object(_) => vec![(1.into(), lists.clone())],
_ => return None,
}
};
let lists: Vec<Value> = entries
.into_iter()
.map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)}))
.collect();
let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists});
if let Some(mode) = p.get("mode") {
out["mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's `condition` as `horizon:condition`, when the game runs
/// every condition in it (true, all_of, any_of, not, random_chance,
/// matching_blocks with plain property values); else it stays Java.
fn condition(
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Value> {
fn runs(c: &Value) -> bool {
if let Some(all) = c.as_array() {
return all.iter().all(runs);
}
let kind = c["type"].as_str().unwrap_or_default();
match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
"true" | "random_chance" => true,
"all_of" | "any_of" => c["conditions"]
.as_array()
.is_some_and(|a| a.iter().all(runs)),
"not" => runs(&c["condition"]),
"matching_blocks" => c.get("properties").is_none_or(|p| {
p.as_object()
.is_some_and(|o| o.values().all(Value::is_string))
}),
_ => false,
}
}
if !runs(&p["if_true"]) {
return None;
}
// One processor or a list of them.
let mut branch = |key: &str| -> Value {
let list = match p.get(key) {
None => Vec::new(),
Some(Value::Array(a)) => a.clone(),
Some(one) => vec![one.clone()],
};
carry_all(list, data, carried).0.into()
};
let (then, otherwise) = (branch("then"), branch("else"));
let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"],
"then": then, "else": otherwise});
if let Some(mode) = p.get("random_mode") {
out["random_mode"] = mode.clone();
}
Some(out)
}
/// Lithostitched's references in a list, each replaced by the processors of
/// the lists it names, in order (references in them too, to a depth of 8).
/// One naming a tag or a list the mod does not have stays, to be dropped.
/// Whether any was replaced.
fn references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
depth: usize,
) -> (Vec<Value>, bool) {
let mut out = Vec::with_capacity(list.len());
let mut changed = false;
for p in list {
if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) {
changed = true;
out.extend(references(inner, data, carried, depth + 1).0);
continue;
}
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
.then(|| referenced(&p["processor_lists"], data))
.flatten();
match named {
Some(inner) => {
*carried.entry("lithostitched:reference".into()).or_default() += 1;
out.extend(references(inner, data, carried, depth + 1).0);
changed = true;
}
None => out.push(p),
}
}
(out, changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_structure_mods::data;
use crate::processors::carry;
#[test]
fn modifiers_add_to_sets_and_pools() {
let mut report = Upgrade::default();
let only_fd = |m: &str| m == "farmersdelight";
let set = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "minecraft:villages",
"entries": [{"structure": "x:village", "weight": 4}]});
let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap();
assert_eq!(kind, "structure_set");
assert_eq!(
added,
json!({"horizon:add_to": ["minecraft:villages"],
"structures": [{"structure": "x:village", "weight": 4}]})
);
// A compat addition holds only when its mod is ported along.
let pool = |modid: &str| {
json!({"type": "lithostitched:add_template_pool_elements",
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}],
"fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]},
"template_pools": ["x:houses", "y:houses"],
"elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element",
"location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]})
};
let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"]));
assert_eq!(added["elements"][0]["weight"], 5);
assert!(modifier(&pool("create"), &only_fd, &mut report).is_none());
// Fabric's conditions when there are no NeoForge ones; lithostitched's own.
let fabric = json!({"type": "lithostitched:add_structure_set_entries",
"fabric:load_conditions": [{"condition": "fabric:not",
"value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}],
"predicate": {"type": "lithostitched:all_of", "predicates": [
{"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"},
{"type": "lithostitched:true"}]},
"structure_sets": "minecraft:villages", "entries": []});
assert!(modifier(&fabric, &only_fd, &mut report).is_some());
let tagged = json!({"type": "lithostitched:add_structure_set_entries",
"structure_sets": "#x:sets", "entries": []});
assert!(modifier(&tagged, &only_fd, &mut report).is_none());
let features = json!({"type": "lithostitched:add_features"});
assert!(modifier(&features, &only_fd, &mut report).is_none());
assert_eq!(
report.added_to,
BTreeMap::from([
("minecraft:villages".into(), 1),
("x:houses".into(), 1),
("y:houses".into(), 1),
])
);
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:add_features".into(), 1),
("target".into(), 1),
])
);
}
#[test]
fn lithostitched_block_swaps_become_the_games() {
let mut list = json!({"processors": [
{"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}},
{"processor_type": "lithostitched:block_swap", "blocks": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data(), &mut carried));
let p = list["processors"].as_array().unwrap();
assert_eq!(
p[0],
json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}})
);
// A map that is not one stays Java (dropped later).
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
}
#[test]
fn lithostitched_references_are_written_in() {
let mut data = data();
let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0});
data.lists
.insert("v:road".into(), json!({"processors": [gravity.clone()]}));
data.lists.insert(
"v:house".into(),
json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:road"},
{"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}),
);
let mut list = json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]},
{"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"},
{"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
// House (its own reference to road first, then its swap), then road;
// the swap inside is carried too.
assert_eq!(p[0], gravity);
assert_eq!(p[1]["processor_type"], "horizon:block_swap");
assert_eq!(p[2], gravity);
// A tag or a list the mod lacks stays, to be dropped.
assert_eq!(p[3]["processor_lists"], "#v:roads");
assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"]));
assert_eq!(carried["lithostitched:reference"], 2);
}
#[test]
fn lithostitched_random_and_condition_become_the_games() {
let mut data = data();
let swap = json!({"processor_type": "lithostitched:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
data.lists
.insert("v:green".into(), json!({"processors": [swap.clone()]}));
let mut list = json!({"processors": [
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": "v:green"},
{"processor_type": "v:java"}]}}]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": ["v:green", "v:red"]},
{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": "#v:colours"},
{"processor_type": "lithostitched:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}},
{"processor_type": "lithostitched:condition",
"if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"},
"then": []}]});
let mut carried = BTreeMap::new();
assert!(carry("processor_list", &mut list, &data, &mut carried));
let p = list["processors"].as_array().unwrap();
let green = json!({"processor_type": "horizon:block_swap",
"blocks": {"minecraft:dirt": "minecraft:grass_block"}});
// Inline lists are carried; Java left in them is dropped later.
assert_eq!(
p[0],
json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 3, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]})
);
assert_eq!(
p[1]["processor_lists"],
json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}])
);
assert_eq!(p[2]["processor_type"], "lithostitched:apply_random");
assert_eq!(
p[3],
json!({"processor_type": "horizon:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider":
{"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]})
);
assert_eq!(p[4]["processor_type"], "lithostitched:condition");
assert_eq!(carried["lithostitched:apply_random"], 2);
assert_eq!(carried["lithostitched:condition"], 1);
}
}
@@ -13,10 +13,11 @@
//! The general code reaches them only through the `pub(crate)` entry points
//! of these three modules. Nothing in them is used by another mod's
//! conversion.
#[allow(dead_code)] // its modular compat is `crate::lithostitched`'s now; the rest goes with C1
pub(crate) mod ctov;
pub(crate) mod lithostitched;
#[cfg(test)]
#[allow(dead_code)] // the old Lithostitched tests used it
fn data() -> crate::processors::ProcessorData {
let mut d = crate::processors::ProcessorData::default();
d.spawners.insert(
+1
View File
@@ -14,6 +14,7 @@ pub mod import;
pub mod inventory;
pub mod jar;
pub(crate) mod legacy_structure_mods;
mod lithostitched;
pub mod modrinth;
pub mod oracle;
pub mod pipeline;
@@ -0,0 +1,434 @@
//! Lithostitched's worldgen, carried as the game reads it (clean room:
//! `cleanroom/structures/spec-lithostitched.md`; CTOV's processor:
//! `spec-ctov.md` §4).
//!
//! - `lithostitched:jigsaw` structures become vanilla's jigsaw. Lithostitched
//! lays jigsaws out its own way (OPEN-L5): Horizon keeps vanilla's layouts.
//! - Its processors (`block_swap`, `reference`, `apply_random`, `condition`,
//! `set_block`, `discard_input`) are the game's own types
//! (`horizon_world`'s `structure::processor`): they are carried as written,
//! the processors nested in them carried in turn. CTOV's
//! `ctov:modular_compat` (a reference that runs only when its mod is loaded)
//! becomes a `reference` when the mod is ported along, else nothing.
//! - `add_template_pool_elements` modifiers become `horizon:add_to` pool files
//! (`docs/mods.md`), when their load conditions pass. Lithostitched's own
//! pool elements (`limited`, `guaranteed`, `delegating`) place their
//! `delegate`; what they add to it is counted as lost.
use crate::{processors::ProcessorData, worldgen::Upgrade};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const JIGSAW: &str = "lithostitched:jigsaw";
/// Vanilla's JigsawStructure.MAX_DEPTH.
const MAX_SIZE: i64 = 20;
const MODULAR_COMPAT: &str = "ctov:modular_compat";
/// The processor types the game reads.
const PROCESSORS: [&str; 6] = [
"lithostitched:block_swap",
"lithostitched:reference",
"lithostitched:apply_random",
"lithostitched:condition",
"lithostitched:set_block",
"lithostitched:discard_input",
];
fn count(map: &mut BTreeMap<String, usize>, name: &str) {
*map.entry(name.to_owned()).or_default() += 1;
}
/// A `lithostitched:jigsaw` structure as vanilla's jigsaw: `Some(true)`, else
/// `None`.
pub(crate) fn upgrade(
kind: &str,
v: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> Option<bool> {
if kind != "structure" || v["type"] != JIGSAW {
return None;
}
v["type"] = "minecraft:jigsaw".into();
// `size` may be an int provider (sampled per start): its largest value.
let size = match &v["size"] {
Value::Null => None,
Value::Number(n) => n.as_i64(),
provider => {
count(lost, "lithostitched:jigsaw size provider");
["max_inclusive", "value"]
.iter()
.find_map(|k| provider[k].as_i64())
}
};
if let Some(size) = size {
if size > MAX_SIZE {
count(lost, "lithostitched:jigsaw size over 20");
}
v["size"] = size.clamp(0, MAX_SIZE).into();
}
let o = v.as_object_mut()?;
match o.remove("start_projection") {
Some(Value::String(heightmap)) if heightmap != "floor" && heightmap != "ceiling" => {
o.entry("project_start_to_heightmap")
.or_insert(heightmap.into());
}
Some(_) => count(lost, "lithostitched:jigsaw start_projection"),
None => {}
}
if o.remove("fixed_rotation").is_some_and(|f| f == true) {
count(lost, "lithostitched:jigsaw fixed_rotation");
}
Some(true)
}
/// Lithostitched's pool elements as the element they wrap; whether `element`
/// was one.
pub(crate) fn upgrade_pool_element(
element: &mut Value,
lost: &mut BTreeMap<String, usize>,
) -> bool {
let Some(kind) = element["element_type"].as_str() else {
return false;
};
match kind {
"lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => {
count(lost, kind);
let delegate = element["delegate"].take();
*element = delegate;
// A delegate wrapping another (only `delegating` allows it).
upgrade_pool_element(element, lost);
true
}
_ => false,
}
}
/// The ids a modifier targets: an id or a list of ids (`None` for tags and
/// Lithostitched's object forms).
fn ids(v: &Value) -> Option<Vec<String>> {
let id = |v: &Value| {
v.as_str()
.filter(|s| !s.starts_with('#'))
.map(str::to_owned)
};
match v {
Value::Array(a) => a.iter().map(id).collect(),
other => Some(vec![id(other)?]),
}
}
/// NeoForge's load conditions (`neoforge:conditions`): all must pass.
fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
fn pass(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool {
let all = |key: &str| {
c[key]
.as_array()
.into_iter()
.flatten()
.all(|c| pass(c, loaded))
};
match c["type"].as_str() {
Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded),
Some("neoforge:true") => true,
Some("neoforge:not") => !pass(&c["value"], loaded),
Some("neoforge:and") => all("values"),
Some("neoforge:or") => {
(c["values"].as_array().into_iter().flatten()).any(|c| pass(c, loaded))
}
_ => false,
}
}
(v["neoforge:conditions"].as_array().into_iter().flatten()).all(|c| pass(c, loaded))
}
/// A worldgen modifier as a file the game merges: `("template_pool", file)`
/// for `add_template_pool_elements`; the others are counted in
/// `report.modifiers_left`.
pub(crate) fn modifier(
v: &Value,
loaded: &dyn Fn(&str) -> bool,
report: &mut Upgrade,
) -> Option<(&'static str, Value)> {
let kind = v["type"].as_str().unwrap_or("no type");
if !conditions_pass(v, loaded) {
count(&mut report.modifiers_left, "conditions");
return None;
}
if kind != "lithostitched:add_template_pool_elements" {
count(&mut report.modifiers_left, kind);
return None;
}
let (Some(targets), Some(elements)) = (ids(&v["template_pools"]), v["elements"].as_array())
else {
count(&mut report.modifiers_left, "target");
return None;
};
for target in &targets {
*report.added_to.entry(target.clone()).or_default() += elements.len();
}
Some((
"template_pool",
json!({"horizon:add_to": targets, "elements": elements}),
))
}
/// Whether the game reads a Lithostitched processor as it is: one of its
/// types, with no processor list named by tag (the game has no processor
/// list tags) and a state provider it runs.
pub(crate) fn game_reads(p: &Value) -> bool {
let Some(kind) = p["processor_type"]
.as_str()
.filter(|t| PROCESSORS.contains(t))
else {
return false;
};
let tag = |v: &Value| v.as_str().is_some_and(|s| s.starts_with('#'));
match kind {
"lithostitched:reference" => match &p["processor_lists"] {
Value::Array(a) => !a.iter().any(tag),
other => other.is_string() && !tag(other),
},
"lithostitched:apply_random" => p["processor_lists"]
.as_array()
.is_some_and(|a| a.iter().all(|e| !tag(e) && !tag(&e["data"]))),
"lithostitched:set_block" => matches!(
p["state_provider"]["type"].as_str(),
Some("minecraft:simple_state_provider" | "minecraft:weighted_state_provider")
),
"lithostitched:block_swap" => p["blocks"].is_object(),
"lithostitched:condition" => !p["if_true"].is_null(),
_ => true,
}
}
/// The processor lists inside a Lithostitched processor, for the general
/// code that walks lists (`then`, `else`, `apply_random`'s inline data).
pub(crate) fn inner_lists(p: &mut Value) -> Vec<&mut Value> {
match p["processor_type"].as_str() {
Some("lithostitched:apply_random") => (p["processor_lists"].as_array_mut().into_iter())
.flatten()
.filter_map(|e| e.get_mut("data").filter(|d| !d.is_string()))
.collect(),
Some("lithostitched:condition") => (p.as_object_mut().into_iter())
.flat_map(|o| o.iter_mut())
.filter(|(k, _)| *k == "then" || *k == "else")
.map(|(_, v)| v)
.collect(),
_ => Vec::new(),
}
}
/// A Lithostitched processor with the processors nested in it carried
/// (`Some`, when one of them changed); `None` for any other processor.
pub(crate) fn processor(
kind: &str,
p: &Value,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> Option<Vec<Value>> {
if !PROCESSORS.contains(&kind) {
return None;
}
let mut out = p.clone();
let mut changed = false;
for list in inner_lists(&mut out) {
// One processor alone is a list of one.
if list.get("processor_type").is_some() {
*list = Value::Array(vec![list.take()]);
changed = true;
}
if let Some(processors) = crate::processors::processors(list) {
let (inner, inner_changed) =
crate::processors::carry_all(std::mem::take(processors), data, carried);
*processors = inner;
changed |= inner_changed;
}
}
changed.then(|| vec![out])
}
/// The list with CTOV's modular compat processors written as the game's
/// `reference` (its mod is ported along) or taken out; whether any was.
pub(crate) fn expand_references(
list: Vec<Value>,
data: &ProcessorData,
carried: &mut BTreeMap<String, usize>,
) -> (Vec<Value>, bool) {
let mut changed = false;
let mut out = Vec::with_capacity(list.len());
for p in list {
if p["processor_type"] != MODULAR_COMPAT {
out.push(p);
continue;
}
changed = true;
count(carried, MODULAR_COMPAT);
let loaded = p["modid"]
.as_str()
.is_some_and(|m| data.compat_loaded.contains(m));
if loaded {
out.push(json!({"processor_type": "lithostitched:reference",
"processor_lists": p["reference"].clone()}));
}
}
(out, changed)
}
/// Notes which mods CTOV's modular compat processors name are `loaded`.
pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) {
fn visit(v: &Value, out: &mut Vec<String>) {
match v {
Value::Object(o) => {
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT)
&& let Some(m) = o.get("modid").and_then(Value::as_str)
{
out.push(m.to_owned());
}
o.values().for_each(|v| visit(v, out));
}
Value::Array(a) => a.iter().for_each(|v| visit(v, out)),
_ => {}
}
}
let mut named = Vec::new();
data.lists.values().for_each(|l| visit(l, &mut named));
data.compat_loaded
.extend(named.into_iter().filter(|m| loaded(m)));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::worldgen::upgrade as upgrade_file;
#[test]
fn jigsaws_become_vanillas() {
let mut lost = BTreeMap::new();
let mut s = json!({"type": JIGSAW, "size": {"type": "uniform", "min_inclusive": 3,
"max_inclusive": 40}, "start_projection": "WORLD_SURFACE_WG", "fixed_rotation": true});
assert_eq!(upgrade("structure", &mut s, &mut lost), Some(true));
assert_eq!(
s,
json!({"type": "minecraft:jigsaw", "size": 20,
"project_start_to_heightmap": "WORLD_SURFACE_WG"})
);
assert_eq!(lost.len(), 3);
assert_eq!(
upgrade(
"structure",
&mut json!({"type": "minecraft:jigsaw"}),
&mut lost
),
None
);
}
#[test]
fn wrapped_elements_place_their_delegate() {
let mut lost = BTreeMap::new();
let delegate = json!({"element_type": "minecraft:single_pool_element", "location": "a:b",
"projection": "rigid", "processors": "minecraft:empty"});
let mut e =
json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate});
assert!(upgrade_pool_element(&mut e, &mut lost));
assert_eq!(e, delegate);
assert_eq!(lost["lithostitched:limited"], 1);
assert!(!upgrade_pool_element(&mut e, &mut lost));
}
#[test]
fn modifiers_add_to_pools_when_their_mods_are_loaded() {
let mut report = Upgrade::default();
let loaded = |m: &str| m == "waystones";
let add = |modid: &str, pools: Value| {
json!({"type": "lithostitched:add_template_pool_elements", "template_pools": pools,
"elements": [{"weight": 2, "element": {"element_type": "minecraft:empty_pool_element"}}],
"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}]})
};
let (kind, file) = modifier(
&add("waystones", json!("ctov:village/plains/house")),
&loaded,
&mut report,
)
.unwrap();
assert_eq!(kind, "template_pool");
assert_eq!(file["horizon:add_to"], json!(["ctov:village/plains/house"]));
assert_eq!(file["elements"][0]["weight"], 2);
assert_eq!(report.added_to["ctov:village/plains/house"], 1);
assert!(modifier(&add("sawmill", json!("a:b")), &loaded, &mut report).is_none());
assert!(modifier(&add("waystones", json!("#a:pools")), &loaded, &mut report).is_none());
let aliases = json!({"type": "lithostitched:set_pool_aliases"});
assert!(modifier(&aliases, &loaded, &mut report).is_none());
assert_eq!(
report.modifiers_left,
BTreeMap::from([
("conditions".into(), 1),
("lithostitched:set_pool_aliases".into(), 1),
("target".into(), 1),
])
);
}
/// A CTOV list (spec-ctov §4): references, a condition with a single
/// processor and a nested condition, a random list holding the compat
/// hook, and processors the game cannot read.
#[test]
fn processors_stay_lithostitcheds_with_their_insides_carried() {
let hook = json!({"processor_type": MODULAR_COMPAT, "modid": "natures_spirit",
"reference": "ctov:village/mesa/house_wisteria"});
let list = json!({"processors": [
{"processor_type": "lithostitched:reference", "processor_lists": ["ctov:village/mesa/house"]},
{"processor_type": "lithostitched:condition", "random_mode": "per_block",
"if_true": {"type": "lithostitched:matching_blocks", "blocks": "minecraft:water",
"match_type": "location"},
"then": {"processor_type": "lithostitched:set_block", "state_provider": {
"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:spruce_planks"}}},
"else": [{"processor_type": "somemod:java_processor"}]},
{"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [
{"weight": 1, "data": "minecraft:empty"},
{"weight": 1, "data": {"processors": [hook]}}]},
{"processor_type": "lithostitched:reference", "processor_lists": "#ctov:palettes"},
{"processor_type": "lithostitched:set_block", "state_provider": {
"type": "minecraft:noise_provider"}}]});
for (loaded, wisteria) in [
(false, json!([])),
(
true,
json!([{"processor_type":
"lithostitched:reference", "processor_lists": "ctov:village/mesa/house_wisteria"}]),
),
] {
let mut data = ProcessorData::default();
data.lists
.insert("ctov:village/mesa/house".into(), list.clone());
mark_loaded(&mut data, &|m| loaded && m == "natures_spirit");
let mut v = list.clone();
let mut report = Upgrade::default();
crate::processors::carry(
"processor_list",
&mut v,
&data,
&mut report.processors_carried,
);
upgrade_file("processor_list", &mut v, &mut report, &BTreeMap::new());
let p = &v["processors"];
assert_eq!(p.as_array().unwrap().len(), 3, "{p}");
assert_eq!(p[0], list["processors"][0]);
// One processor is a list of one; the Java one is taken out.
assert_eq!(
p[1]["then"][0]["state_provider"]["state"]["id"],
"minecraft:spruce_planks"
);
assert_eq!(p[1]["else"], json!([]));
assert_eq!(p[2]["processor_lists"][0]["data"], "minecraft:empty");
assert_eq!(p[2]["processor_lists"][1]["data"]["processors"], wisteria);
assert_eq!(report.processors_carried[MODULAR_COMPAT], 1);
assert_eq!(
report.processors_dropped,
BTreeMap::from([
("lithostitched:reference".into(), 1),
("lithostitched:set_block".into(), 1),
("somemod:java_processor".into(), 1),
])
);
}
}
}
+5 -6
View File
@@ -10,13 +10,13 @@
//! - Repurposed Structures' mineshaft sky view becomes the game's
//! `horizon:mineshaft_sky_view`.
//!
//! The processors of YUNG's mods, Lithostitched and Choice Theorem's Overhauled
//! Village are carried by `legacy_structure_mods`, called from [`carry`].
//! Lithostitched's processors and Choice Theorem's Overhauled Village's
//! compat hook are carried by `crate::lithostitched`, called from [`carry`].
//!
//! What is not here is taken out of its list later and counted
//! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an
//! `equipment` map, not 1.21.1's `HandItems` and `ArmorItems`).
use crate::legacy_structure_mods::{ctov, lithostitched};
use crate::lithostitched;
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -42,7 +42,7 @@ pub struct ProcessorData {
impl ProcessorData {
/// Notes which mods its compat processors name are `loaded`.
pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) {
ctov::mark_loaded(self, loaded);
lithostitched::mark_loaded(self, loaded);
}
pub fn read<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> Self {
@@ -177,8 +177,7 @@ fn carry_at(
let mut out = Vec::with_capacity(expanded.len());
for p in expanded {
let kind = p["processor_type"].as_str().unwrap_or_default().to_owned();
// Lithostitched's processors are carried apart from this code
// (`legacy_structure_mods`).
// Lithostitched's processors stay theirs, with what they hold carried.
let replacement = lithostitched::processor(&kind, &p, data, carried).or_else(|| {
match pillar_type(&kind) {
Some(to) => Some(vec![pillar(&p, to, data, carried, depth)]),
+12 -19
View File
@@ -15,10 +15,10 @@
//! closely.
//!
//! YUNG's API's files are carried as written ([`crate::yung`]).
//! Lithostitched's and CTOV's worldgen files are converted by
//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the
//! processors.
use crate::{legacy_structure_mods::lithostitched, yung};
//! Lithostitched's jigsaws, elements, modifiers and processors are carried by
//! [`crate::lithostitched`]; the entries CTOV adds to vanilla's structure sets
//! by `legacy_structure_mods`, called from [`crate::carry`].
use crate::{lithostitched, yung};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
@@ -191,28 +191,19 @@ fn drop_java_processors(list: &mut Value, dropped: &mut BTreeMap<String, usize>)
let before = processors.len();
processors.retain(|p| {
let t = p["processor_type"].as_str().unwrap_or_default();
let java = !t.starts_with("minecraft:") && !t.starts_with("horizon:");
let java = !t.starts_with("minecraft:")
&& !t.starts_with("horizon:")
&& !lithostitched::game_reads(p);
if java {
*dropped.entry(t.to_owned()).or_default() += 1;
}
!java
});
let mut changed = processors.len() != before;
// The inline lists inside the game's apply_random and condition.
// The inline lists inside Lithostitched's apply_random and condition.
for p in processors.iter_mut() {
let inner: Vec<&mut Value> = match p["processor_type"].as_str() {
Some("horizon:apply_random") => p["processor_lists"]
.as_array_mut()
.into_iter()
.flatten()
.map(|e| &mut e["data"])
.collect(),
Some("horizon:condition") => {
let o = p.as_object_mut().into_iter().flat_map(|o| o.iter_mut());
o.filter(|(k, _)| *k == "then" || *k == "else")
.map(|(_, v)| v)
.collect()
}
Some(t) if t.starts_with("lithostitched:") => lithostitched::inner_lists(p),
Some("horizon:pillar" | "horizon:bottom_pillar") => {
p.get_mut("pillar_processor_list").into_iter().collect()
}
@@ -398,7 +389,9 @@ fn upgrade_types(
}
}
for element in elements {
if yung::upgrade_pool_element(element, lost) {
if yung::upgrade_pool_element(element, lost)
|| lithostitched::upgrade_pool_element(element, lost)
{
changed = true;
continue;
}
+5
View File
@@ -60,6 +60,10 @@ pub struct CheckedStage {
pub label: String,
pub module: naga::Module,
pub info: ModuleInfo,
/// The stage's WGSL without the entry points (the prelude, the shared
/// files, the file), for a renderer that calls the stage function from
/// its own shader (the sky).
pub source: String,
pub expressions: usize,
pub texture_reads: usize,
}
@@ -169,6 +173,7 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedSt
label: file.to_owned(),
module,
info,
source: own.text,
expressions,
texture_reads,
})
@@ -21,7 +21,7 @@ use horizon_core::{
use serde_json::Value;
use std::{collections::BTreeSet, sync::Arc};
mod lithostitched;
mod litho;
mod pillars;
mod sea_level;
@@ -238,6 +238,23 @@ impl Modifier {
)
}
}
impl Modifier {
/// RuleBlockEntityModifier.apply: the block entity the output gets.
fn apply(&self, nbt: Option<&Compound>, random: &mut impl ProcessorRandom) -> Option<Compound> {
match self {
Self::Passthrough => nbt.cloned(),
Self::Clear => Some(Compound::new()),
Self::AppendLoot(table) => {
let mut nbt = nbt.cloned().unwrap_or_default();
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
nbt.insert("LootTableSeed".into(), Tag::Long(random.long()));
Some(nbt)
}
Self::AppendStatic(data) => Some(edited(nbt, &[], data)),
Self::Edit { remove, data } => Some(edited(nbt, remove, data)),
}
}
}
/// A block entity with `remove`'s keys taken out and `data` merged in
/// (append_static is the one with nothing to remove).
fn edited(nbt: Option<&Compound>, remove: &[String], data: &Compound) -> Compound {
@@ -292,17 +309,21 @@ pub enum Processor {
SpawnerMobs(Box<SpawnerMobs>),
Pick(Box<Pick>),
CountCap(Box<CountCap>),
/// `horizon:block_swap` (Lithostitched's `block_swap`): blocks by name
/// become others, keeping the properties both have.
/// `lithostitched:block_swap`: blocks by name become others, keeping
/// the properties both have (a block the game lacks is left alone).
BlockSwap(std::collections::BTreeMap<String, String>),
/// `horizon:apply_random` (Lithostitched's `apply_random`): a processor
/// list drawn by weight runs on the block.
ApplyRandom(Box<lithostitched::ApplyRandom>),
/// `horizon:condition` (Lithostitched's `condition`): `then` or `else`
/// `lithostitched:apply_random`: a processor list drawn by weight runs
/// on the block.
ApplyRandom(Box<litho::ApplyRandom>),
/// `lithostitched:reference`: the named lists run on the block in turn.
Reference(Vec<ProcessorList>),
/// `lithostitched:discard_input`: the world's block stays.
DiscardInput,
/// `lithostitched:condition`: `then` or `else`
/// runs on the block, by a test of it.
Condition(Box<lithostitched::Conditional>),
/// `horizon:set_block` (Lithostitched's `set_block`).
SetBlock(Box<lithostitched::SetBlock>),
Condition(Box<litho::Conditional>),
/// `lithostitched:set_block`: the block from a state provider.
SetBlock(Box<litho::SetBlock>),
/// `horizon:pillar` (Repurposed Structures' and Moog's pillars): a
/// trigger block starts a column down to the ground.
Pillar(Box<pillars::Pillar>),
@@ -786,7 +807,7 @@ impl Processor {
})?))
}
"horizon:pick" => Self::Pick(Box::new(Pick::decode(value, state)?)),
"horizon:block_swap" => Self::BlockSwap(
"lithostitched:block_swap" => Self::BlockSwap(
value["blocks"]
.as_object()
.ok_or(DecodeError::Field("blocks"))?
@@ -801,12 +822,16 @@ impl Processor {
.collect::<Result<_, DecodeError>>()?,
),
"horizon:count_cap" => Self::CountCap(Box::new(CountCap::decode(value, state)?)),
"horizon:apply_random" => Self::ApplyRandom(Box::new(
lithostitched::ApplyRandom::decode(value, resources, bindings)?,
"lithostitched:apply_random" => Self::ApplyRandom(Box::new(
litho::ApplyRandom::decode(value, resources, bindings)?,
)),
"horizon:set_block" => {
Self::SetBlock(Box::new(lithostitched::SetBlock::decode(value, state)?))
"lithostitched:set_block" => {
Self::SetBlock(Box::new(litho::SetBlock::decode(value, state)?))
}
"lithostitched:reference" => {
Self::Reference(litho::reference(value, resources, bindings)?)
}
"lithostitched:discard_input" => Self::DiscardInput,
"horizon:pillar" => Self::Pillar(Box::new(pillars::Pillar::decode(
value, resources, bindings, state,
)?)),
@@ -834,7 +859,7 @@ impl Processor {
"horizon:keep_world_block" => {
Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?))
}
"horizon:condition" => Self::Condition(Box::new(lithostitched::Conditional::decode(
"lithostitched:condition" => Self::Condition(Box::new(litho::Conditional::decode(
value, resources, bindings,
)?)),
_ => return Err(DecodeError::Field("structure processor type")),
@@ -1115,24 +1140,10 @@ fn process_block<W: PlacementContext>(
if !position_ok {
continue;
}
let nbt = match &rule.modifier {
Modifier::Passthrough => info.nbt.clone(),
Modifier::Clear => Some(Compound::new()),
Modifier::AppendLoot(table) => {
let mut nbt = info.nbt.clone().unwrap_or_default();
nbt.insert("LootTable".into(), Tag::String(table.as_str().into()));
nbt.insert("LootTableSeed".into(), Tag::Long(random.next_long()));
Some(nbt)
}
Modifier::AppendStatic(data) => Some(edited(info.nbt.as_ref(), &[], data)),
Modifier::Edit { remove, data } => {
Some(edited(info.nbt.as_ref(), remove, data))
}
};
return Ok(Some(BlockInfo {
pos: info.pos,
state: rule.output,
nbt,
nbt: rule.modifier.apply(info.nbt.as_ref(), &mut random),
}));
}
Some(info)
@@ -1165,8 +1176,7 @@ fn process_block<W: PlacementContext>(
false => Some(info),
},
Processor::ApplyRandom(apply) => {
let seed = world.world_seed()?;
let mut random = apply.random.create(seed, position, reference, info.pos);
let mut random = apply.draws.random(world.world_seed()?, position, info.pos);
match apply.pick(&mut random) {
Some(list) => {
return process_list(
@@ -1176,21 +1186,34 @@ fn process_block<W: PlacementContext>(
None => Some(info),
}
}
Processor::Reference(lists) => {
let mut current = info;
for list in lists {
match process_list(
list, world, resources, position, reference, relative, current, settings,
)? {
Some(next) => current = next,
None => return Ok(None),
}
}
Some(current)
}
Processor::DiscardInput => None,
Processor::SetBlock(set) => {
let seed = world.world_seed()?;
let state = set.state(seed, position, reference, info.pos);
let state = match set.preserves() {
let mut random = set.draws.random(world.world_seed()?, position, info.pos);
let state = set.state(&mut random);
let state = match set.preserve {
true => registry
.state(state)
.and_then(|s| with_properties_of(registry, &s.name, info.state))
.unwrap_or(state),
false => state,
};
Some(BlockInfo { state, ..info })
let nbt = set.modifier.apply(info.nbt.as_ref(), &mut random);
Some(BlockInfo { state, nbt, ..info })
}
Processor::Condition(c) => {
let seed = world.world_seed()?;
let mut random = c.random.create(seed, position, reference, info.pos);
let mut random = c.draws.random(world.world_seed()?, position, info.pos);
let location = world.block_state(info.pos)?;
let passed = c
.condition
@@ -0,0 +1,841 @@
//! Lithostitched's processors (spec-lithostitched §5), read as its data
//! writes them: `lithostitched:block_swap` (in `processor.rs`), `reference`,
//! `apply_random`, `condition`, `set_block` and `discard_input`.
//!
//! The draws of `random_chance`, `apply_random` and `set_block` follow
//! Lithostitched's `random_mode` (per block or per piece), but its seeding is
//! not known (OPEN-L6): a positional random of the world seed, the block's or
//! the piece's position and the processor itself, so the shares match while
//! the blocks chosen do not.
use super::{BlockStateId, DecodeError, Modifier, ProcessorList, ProcessorRandom};
use crate::worldgen::{block_feature::FeatureBlockBindings, structure::StructureResources};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, position_seed},
};
use serde_json::Value;
use std::collections::BTreeSet;
/// `random_mode` (`condition`, `set_block`) or `mode` (`apply_random`).
#[derive(Clone, Copy, Debug)]
pub(super) struct Draws {
per_piece: bool,
/// The processor's own salt, so two processors on a block draw apart.
salt: i64,
}
impl Draws {
fn decode(mode: Option<&Value>, processor: &Value) -> Result<Self, DecodeError> {
let per_piece = match mode.and_then(Value::as_str) {
None | Some("per_block") => false,
Some("per_piece") => true,
_ => return Err(DecodeError::Field("random_mode")),
};
// FNV-1a over the processor's JSON: the same in every run.
let salt = (processor.to_string().bytes()).fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
}) as i64;
Ok(Self { per_piece, salt })
}
/// The random for the block at `pos` of the piece at `piece`.
pub(super) fn random(self, world_seed: i64, piece: [i32; 3], pos: [i32; 3]) -> LegacyRandom {
let [x, y, z] = if self.per_piece { piece } else { pos };
LegacyRandom::new(world_seed ^ self.salt ^ position_seed(BlockPos::new(x, y, z)))
}
}
/// A processor or a list of them (`then`, `else`, `apply_random`'s data):
/// a list holder (`ProcessorList::decode`), or one processor object.
fn processors(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<ProcessorList, DecodeError> {
let one = value.get("processor_type").is_some();
let holder = if one {
Value::Array(vec![value.clone()])
} else {
value.clone()
};
ProcessorList::decode(&holder.to_string(), resources, bindings)
}
/// `reference`: the named lists, run in turn on the block.
pub(super) fn reference(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Vec<ProcessorList>, DecodeError> {
let names = match &value["processor_lists"] {
Value::Array(a) => a.clone(),
other => vec![other.clone()],
};
(names.iter())
.map(|name| match name.as_str() {
Some(n) if n.starts_with('#') => Err(DecodeError::Field("processor list tag")),
Some(_) => ProcessorList::decode(&name.to_string(), resources, bindings),
None => Err(DecodeError::Field("processor_lists")),
})
.collect()
}
/// `apply_random`: one list drawn by weight runs on the block.
#[derive(Clone, Debug)]
pub struct ApplyRandom {
pub(super) draws: Draws,
lists: Vec<(i32, ProcessorList)>,
total: i32,
}
impl ApplyRandom {
pub(super) fn decode(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let entries = value["processor_lists"]
.as_array()
.ok_or(DecodeError::Field("apply_random processor_lists"))?;
let lists = (entries.iter())
.map(|e| match e.get("data") {
Some(data) => {
let weight = e["weight"]
.as_i64()
.unwrap_or(1)
.clamp(0, i64::from(i32::MAX));
Ok((weight as i32, processors(data, resources, bindings)?))
}
None => Ok((1, processors(e, resources, bindings)?)),
})
.collect::<Result<Vec<_>, DecodeError>>()?;
let total = lists
.iter()
.map(|(w, _)| *w)
.fold(0i32, i32::saturating_add);
Ok(Self {
draws: Draws::decode(value.get("mode"), value)?,
lists,
total,
})
}
/// The list drawn: one bounded draw over the total weight.
pub(super) fn pick(&self, random: &mut impl ProcessorRandom) -> Option<&ProcessorList> {
if self.total <= 0 {
return None;
}
let mut at = random.int(self.total);
for (weight, list) in &self.lists {
at -= weight;
if at < 0 {
return Some(list);
}
}
None
}
}
/// Lithostitched's processor conditions.
#[derive(Clone, Debug)]
pub(super) enum Condition {
True,
AllOf(Vec<Condition>),
AnyOf(Vec<Condition>),
Not(Box<Condition>),
Chance(f32),
/// `matching_blocks`: the template's block (`input`) or the world's
/// (`location`) is one of `blocks`, with `properties`.
Matching {
blocks: BTreeSet<String>,
properties: Vec<(String, String)>,
location: bool,
},
}
impl Condition {
/// One condition, or a list of them that must all pass (`if_true`).
pub(super) fn decode(
value: &Value,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let list = |v: &Value| -> Result<Vec<Self>, DecodeError> {
(v.as_array().ok_or(DecodeError::Field("conditions"))?.iter())
.map(|c| Self::decode(c, bindings))
.collect()
};
if value.is_array() {
return Ok(Self::AllOf(list(value)?));
}
let kind = value["type"].as_str().unwrap_or_default();
Ok(match crate::worldgen::tags::identifier(kind)?.as_str() {
"lithostitched:true" => Self::True,
"lithostitched:all_of" => Self::AllOf(list(&value["conditions"])?),
"lithostitched:any_of" => Self::AnyOf(list(&value["conditions"])?),
"lithostitched:not" => {
Self::Not(Box::new(Self::decode(&value["condition"], bindings)?))
}
"lithostitched:random_chance" => Self::Chance(
value["chance"]
.as_f64()
.ok_or(DecodeError::Field("random_chance chance"))? as f32,
),
"lithostitched:matching_blocks" => Self::Matching {
blocks: blocks(&value["blocks"], bindings)?,
properties: (value
.get("properties")
.and_then(Value::as_object)
.into_iter())
.flatten()
.map(|(k, v)| {
(
k.clone(),
v.as_str().map_or_else(|| v.to_string(), str::to_owned),
)
})
.collect(),
location: match value.get("match_type").and_then(Value::as_str) {
None | Some("input") => false,
Some("location") => true,
_ => return Err(DecodeError::Field("matching_blocks match_type")),
},
},
_ => return Err(DecodeError::Field("lithostitched condition type")),
})
}
pub(super) fn test(
&self,
input: BlockStateId,
location: BlockStateId,
registry: &crate::registry::BlockRegistry,
random: &mut impl ProcessorRandom,
) -> bool {
match self {
Self::True => true,
Self::AllOf(all) => all
.iter()
.all(|c| c.test(input, location, registry, random)),
Self::AnyOf(any) => any
.iter()
.any(|c| c.test(input, location, registry, random)),
Self::Not(c) => !c.test(input, location, registry, random),
Self::Chance(chance) => random.float() < *chance,
Self::Matching {
blocks,
properties,
location: at_location,
} => {
let Some(state) = registry.state(if *at_location { location } else { input })
else {
return false;
};
blocks.contains(&state.name)
&& (properties.iter()).all(|(k, v)| state.properties.get(k) == Some(v))
}
}
}
}
/// A block set (an id, a list, `#tag`) by block name. Blocks the game does
/// not have are left out: no template block can be one.
fn blocks(
value: &Value,
bindings: &FeatureBlockBindings<'_>,
) -> Result<BTreeSet<String>, DecodeError> {
let names: Vec<&str> = match value {
Value::String(s) => vec![s.as_str()],
Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
_ => return Err(DecodeError::Field("matching_blocks blocks")),
};
let registry = bindings.registry();
let mut out = BTreeSet::new();
for name in names {
match name.strip_prefix('#') {
Some(tag) => {
let tag = crate::worldgen::tags::identifier(tag)?;
let states = bindings.tags.get(&tag).into_iter().flat_map(|s| s.iter());
out.extend(states.filter_map(|s| Some(registry.state(*s)?.name.clone())));
}
None => {
out.insert(crate::worldgen::tags::identifier(name)?.to_string());
}
}
}
Ok(out)
}
/// `condition`: `then` when the conditions pass, else `else` (none: the
/// block as it is).
#[derive(Clone, Debug)]
pub struct Conditional {
pub(super) draws: Draws,
pub(super) condition: Condition,
pub(super) then: ProcessorList,
pub(super) otherwise: ProcessorList,
}
impl Conditional {
pub(super) fn decode(
value: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let branch = |key| match value.get(key) {
Some(v) => processors(v, resources, bindings),
None => Ok(ProcessorList(Vec::new())),
};
Ok(Self {
draws: Draws::decode(value.get("random_mode"), value)?,
condition: Condition::decode(&value["if_true"], bindings)?,
then: branch("then")?,
otherwise: branch("else")?,
})
}
}
/// `set_block`: the block from a state provider (simple or weighted).
#[derive(Clone, Debug)]
pub struct SetBlock {
pub(super) draws: Draws,
states: Vec<(i32, BlockStateId)>,
total: i32,
/// `preserve_state` (true by default): the template block's properties
/// the new block has too are kept.
pub(super) preserve: bool,
pub(super) modifier: Modifier,
}
impl SetBlock {
pub(super) fn decode(
value: &Value,
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
) -> Result<Self, DecodeError> {
let provider = &value["state_provider"];
let kind = provider["type"].as_str().unwrap_or_default();
let states = match crate::worldgen::tags::identifier(kind)?.as_str() {
"minecraft:simple_state_provider" => vec![(1, state(&provider["state"])?)],
"minecraft:weighted_state_provider" => (provider["entries"].as_array().into_iter())
.flatten()
.map(|e| {
Ok((
e["weight"].as_i64().unwrap_or(1).clamp(0, 1 << 30) as i32,
state(&e["data"])?,
))
})
.collect::<Result<_, DecodeError>>()?,
_ => return Err(DecodeError::Field("set_block state_provider")),
};
let total = states.iter().map(|(w, _)| *w).sum();
if total <= 0 {
return Err(DecodeError::Field("set_block state_provider"));
}
Ok(Self {
draws: Draws::decode(value.get("random_mode"), value)?,
states,
total,
preserve: value
.get("preserve_state")
.and_then(Value::as_bool)
.unwrap_or(true),
modifier: Modifier::decode(value.get("block_entity_modifier"))?,
})
}
/// The provider's state: WeightedList.getRandom, one bounded draw.
pub(super) fn state(&self, random: &mut impl ProcessorRandom) -> BlockStateId {
let mut at = random.int(self.total);
for (weight, state) in &self.states {
at -= weight;
if at < 0 {
return *state;
}
}
self.states[0].1
}
}
#[cfg(test)]
mod tests {
use super::super::{BlockInfo, PlaceSettings, ProcessorList, process_list};
use crate::{
heightmap::HeightmapType,
nbt::{JavaString, Tag},
registry::{BlockRegistry, BlockStateId},
worldgen::{
anchor::WorldGenerationContext,
block_feature::FeatureBlockBindings,
placement::{PlacementContext, PlacementQueryError},
structure::{
Rotation, StateTransforms, StructureResources, template::parse_state_string,
},
},
};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
/// Every combination of `properties`' values, the first one the default.
fn states(name: &str, properties: &[(&str, &[&str])]) -> Vec<Value> {
let mut out = vec![serde_json::Map::new()];
for (key, values) in properties {
out = (out.iter())
.flat_map(|o| {
values.iter().map(move |v| {
let mut o = o.clone();
o.insert((*key).into(), (*v).into());
o
})
})
.collect();
}
(out.into_iter())
.map(|p| json!([name, name.ends_with("air"), false, "", {"id": name, "properties": p}]))
.collect()
}
/// A registry of the blocks CTOV's lists and the probe pack use.
fn registry() -> &'static BlockRegistry {
let ages: Vec<String> = (0..8).map(|a| a.to_string()).collect();
let ages: Vec<&str> = ages.iter().map(String::as_str).collect();
let levels: Vec<String> = (0..16).map(|a| a.to_string()).collect();
let levels: Vec<&str> = levels.iter().map(String::as_str).collect();
let water = [("level", &levels[..])];
let stairs: &[(&str, &[&str])] =
&[("facing", &["north", "east"]), ("half", &["bottom", "top"])];
let blocks: Vec<(&str, &[(&str, &[&str])])> = vec![
("minecraft:air", &[]),
("minecraft:cave_air", &[]),
("minecraft:void_air", &[]),
("minecraft:lava", &water),
("minecraft:cobblestone", &[]),
("minecraft:blackstone", &[]),
("minecraft:deepslate", &[]),
("minecraft:water", &water),
("minecraft:spruce_planks", &[]),
("minecraft:chest", &[]),
("minecraft:oak_log", &[("axis", &["y", "x"])]),
("minecraft:stripped_cherry_log", &[("axis", &["y", "x"])]),
("minecraft:oak_stairs", stairs),
("minecraft:warped_stairs", stairs),
];
let wheat = [("age", &ages[..])];
let beetroots = [("age", &ages[..4])];
let mut rows = Vec::new();
let mut defaults = BTreeMap::new();
let crops: [(&str, &[(&str, &[&str])]); 3] = [
("minecraft:wheat", &wheat),
("minecraft:carrots", &wheat),
("minecraft:beetroots", &beetroots),
];
for (name, properties) in blocks.into_iter().chain(crops) {
defaults.insert(name, rows.len());
rows.extend(states(name, properties));
}
let facts = json!({"states": rows, "defaults": defaults});
let registry = BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap();
Box::leak(Box::new(registry))
}
/// The world a piece is placed in: one block everywhere.
struct World {
seed: i64,
block: BlockStateId,
}
impl PlacementContext for World {
fn world_seed(&mut self) -> Result<i64, PlacementQueryError> {
Ok(self.seed)
}
fn block_state(&mut self, _: [i32; 3]) -> Result<BlockStateId, PlacementQueryError> {
Ok(self.block)
}
fn generation(&self) -> WorldGenerationContext {
WorldGenerationContext::new(-64, 384, 63)
}
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
Ok(-64)
}
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
Err(PlacementQueryError::Unavailable)
}
}
struct Lists {
registry: &'static BlockRegistry,
resources: StructureResources,
}
impl Lists {
fn new(lists: &[(&str, Value)]) -> Self {
let registry = registry();
let lists = (lists.iter())
.map(|(id, list)| ((*id).to_owned(), list.to_string()))
.collect();
let resources = StructureResources::new(
registry,
StateTransforms::default(),
std::path::Path::new("/nonexistent"),
lists,
&BTreeMap::new(),
);
Self {
registry,
resources,
}
}
fn id(&self, state: &str) -> BlockStateId {
parse_state_string(self.registry, state).unwrap()
}
fn decode(&self, processors: Value) -> Result<ProcessorList, crate::worldgen::DecodeError> {
let bindings = FeatureBlockBindings::new(self.registry, BTreeSet::new()).unwrap();
ProcessorList::decode(&processors.to_string(), &self.resources, &bindings)
}
/// The block the list makes of template block `state` at `pos` of the
/// piece at `piece`, in a world of `world`: None when it is not placed.
fn run(
&self,
list: &ProcessorList,
world: (i64, &str),
piece: [i32; 3],
pos: [i32; 3],
state: &str,
) -> Option<BlockInfo> {
let mut world = World {
seed: world.0,
block: self.id(world.1),
};
let info = BlockInfo {
pos,
state: self.id(state),
nbt: None,
};
let mut settings = PlaceSettings::new(Rotation::None);
process_list(
list,
&mut world,
&self.resources,
piece,
piece,
[0; 3],
info,
&mut settings,
)
.unwrap()
}
fn name(&self, info: Option<BlockInfo>) -> String {
match info {
None => "none".into(),
Some(info) => {
let s = self.registry.state(info.state).unwrap();
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
match props.is_empty() {
true => s.name.clone(),
false => format!("{}[{}]", s.name, props.join(",")),
}
}
}
}
/// `name(run(...))` for one template block in an air world of seed 0.
fn out(&self, list: &ProcessorList, state: &str) -> String {
self.name(self.run(list, (0, "minecraft:air"), [0; 3], [0; 3], state))
}
}
fn swap(blocks: Value) -> Value {
json!({"processor_type": "lithostitched:block_swap", "blocks": blocks})
}
fn set_block(provider: Value) -> Value {
json!({"processor_type": "lithostitched:set_block", "state_provider": provider})
}
fn simple(name: &str) -> Value {
json!({"type": "minecraft:simple_state_provider", "state": {"id": name}})
}
#[test]
fn block_swap_keeps_the_properties_the_new_block_can_have() {
let t = Lists::new(&[]);
let list = t
.decode(json!([swap(json!({
"minecraft:wheat": "minecraft:carrots",
"oak_stairs": "minecraft:warped_stairs",
"minecraft:cobblestone": "natures_spirit:missing"}))]))
.unwrap();
assert_eq!(
t.out(&list, "minecraft:wheat[age=7]"),
"minecraft:carrots[age=7]"
);
assert_eq!(
t.out(&list, "minecraft:oak_stairs[facing=east,half=top]"),
"minecraft:warped_stairs[facing=east,half=top]"
);
// A block the game lacks leaves the template's; others pass.
assert_eq!(
t.out(&list, "minecraft:cobblestone"),
"minecraft:cobblestone"
);
assert_eq!(
t.out(&list, "minecraft:oak_log[axis=x]"),
"minecraft:oak_log[axis=x]"
);
// Beetroots' age stops at 3: ripe wheat gives the default (spec-ctov §4).
let beet = t
.decode(json!([swap(
json!({"minecraft:wheat": "minecraft:beetroots"})
)]))
.unwrap();
assert_eq!(
t.out(&beet, "minecraft:wheat[age=7]"),
"minecraft:beetroots[age=0]"
);
assert_eq!(
t.out(&beet, "minecraft:wheat[age=2]"),
"minecraft:beetroots[age=2]"
);
}
#[test]
fn references_run_the_named_lists_in_turn() {
let t = Lists::new(&[
(
"t:logs",
json!({"processors": [swap(json!({"minecraft:oak_log": "minecraft:stripped_cherry_log"}))]}),
),
(
"t:stone",
json!([swap(
json!({"minecraft:stripped_cherry_log": "minecraft:cobblestone"})
)]),
),
(
"t:discard",
json!([{"processor_type": "lithostitched:discard_input"}]),
),
]);
let reference = |lists: Value| json!([{"processor_type": "lithostitched:reference", "processor_lists": lists}]);
let one = t.decode(reference(json!("t:logs"))).unwrap();
assert_eq!(
t.out(&one, "minecraft:oak_log[axis=x]"),
"minecraft:stripped_cherry_log[axis=x]"
);
let both = t.decode(reference(json!(["t:logs", "t:stone"]))).unwrap();
assert_eq!(t.out(&both, "minecraft:oak_log"), "minecraft:cobblestone");
let reversed = t.decode(reference(json!(["t:stone", "t:logs"]))).unwrap();
assert_eq!(
t.out(&reversed, "minecraft:oak_log"),
"minecraft:stripped_cherry_log[axis=y]"
);
// discard_input: the template block is not placed, and the lists after it do not run.
let discard = t.decode(reference(json!(["t:discard", "t:logs"]))).unwrap();
assert_eq!(t.out(&discard, "minecraft:oak_log"), "none");
// The game has no processor list tags; unknown lists are errors.
assert!(t.decode(reference(json!(["#t:palettes"]))).is_err());
assert!(t.decode(reference(json!("t:missing"))).is_err());
}
/// The probe pack's nested condition (spec-lithostitched §7.3): template
/// cobblestone becomes blackstone or deepslate, half each.
#[test]
fn conditions_run_then_or_else() {
let t = Lists::new(&[]);
let inner = json!({"processor_type": "lithostitched:condition", "random_mode": "per_block",
"if_true": {"type": "lithostitched:random_chance", "chance": 0.5},
"then": set_block(simple("minecraft:blackstone")),
"else": [set_block(simple("minecraft:deepslate"))]});
let list = t
.decode(json!([{"processor_type": "lithostitched:condition",
"if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}],
"then": [inner]}]))
.unwrap();
let mut counts = BTreeMap::<String, u32>::new();
for i in 0..4000 {
let out = t.run(
&list,
(1234, "minecraft:air"),
[0; 3],
[i % 64, i / 64, 7],
"minecraft:cobblestone",
);
*counts.entry(t.name(out)).or_default() += 1;
}
assert_eq!(counts.len(), 2, "{counts:?}");
assert!(
(1850..2150).contains(&counts["minecraft:blackstone"]),
"{counts:?}"
);
// Not cobblestone, and no `else`: the block as it is.
assert_eq!(
t.out(&list, "minecraft:oak_log"),
"minecraft:oak_log[axis=y]"
);
// `location` tests the world's block, with properties on the input.
let water = t
.decode(
json!([{"processor_type": "lithostitched:condition", "random_mode": "per_piece",
"if_true": {"type": "lithostitched:matching_blocks", "blocks": ["minecraft:water"],
"match_type": "location"},
"then": set_block(simple("minecraft:spruce_planks"))}]),
)
.unwrap();
let at = |world| {
t.name(t.run(
&water,
(0, world),
[0; 3],
[1, 2, 3],
"minecraft:cobblestone",
))
};
assert_eq!(at("minecraft:water"), "minecraft:spruce_planks");
assert_eq!(at("minecraft:air"), "minecraft:cobblestone");
let ripe = t
.decode(json!([{"processor_type": "lithostitched:condition",
"if_true": {"type": "lithostitched:all_of", "conditions": [
{"type": "lithostitched:true"},
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:wheat", "properties": {"age": "7"}},
{"type": "lithostitched:not", "condition": {"type": "lithostitched:any_of", "conditions": [
{"type": "lithostitched:random_chance", "chance": 0.0},
{"type": "lithostitched:matching_blocks", "blocks": "minecraft:water", "match_type": "location"}]}}]},
"then": swap(json!({"minecraft:wheat": "minecraft:carrots"}))}]))
.unwrap();
assert_eq!(
t.out(&ripe, "minecraft:wheat[age=7]"),
"minecraft:carrots[age=7]"
);
assert_eq!(
t.out(&ripe, "minecraft:wheat[age=6]"),
"minecraft:wheat[age=6]"
);
let bad = json!([{"processor_type": "lithostitched:condition", "random_mode": "per_chunk",
"if_true": {"type": "lithostitched:true"}}]);
assert!(t.decode(bad).is_err());
}
/// `apply_random` (spec-lithostitched §7.3: crimson 1 : birch 3, one
/// choice per piece): here blackstone 1 : deepslate 3.
#[test]
fn apply_random_draws_per_piece_or_per_block() {
let t = Lists::new(&[(
"t:deepslate",
json!([swap(
json!({"minecraft:cobblestone": "minecraft:deepslate"})
)]),
)]);
let random = |mode: &str| {
t.decode(json!([{"processor_type": "lithostitched:apply_random", "mode": mode,
"processor_lists": [
{"weight": 1, "data": [swap(json!({"minecraft:cobblestone": "minecraft:blackstone"}))]},
{"weight": 3, "data": "t:deepslate"}]}]))
.unwrap()
};
let (per_piece, per_block) = (random("per_piece"), random("per_block"));
let outcomes = |list: &ProcessorList, seed: i64, piece: [i32; 3]| -> Vec<String> {
(0..20)
.map(|i| {
let pos = [piece[0] + i, piece[1], piece[2] + 3 * i];
t.name(t.run(
list,
(seed, "minecraft:air"),
piece,
pos,
"minecraft:cobblestone",
))
})
.collect()
};
let mut blackstone_pieces = 0;
for p in 0..400 {
let piece = [16 * p, 70, -40 * p];
let blocks = outcomes(&per_piece, 1234, piece);
assert!(blocks.iter().all(|b| *b == blocks[0]), "{blocks:?}");
blackstone_pieces += u32::from(blocks[0] == "minecraft:blackstone");
// The same piece and seed give the same blocks.
assert_eq!(outcomes(&per_piece, 1234, piece), blocks);
}
assert!(
(70..130).contains(&blackstone_pieces),
"{blackstone_pieces}"
);
// Per block, one piece mixes both.
let mixed: BTreeSet<String> = (0..10)
.flat_map(|p| outcomes(&per_block, 1234, [p, 0, 0]))
.collect();
assert_eq!(mixed.len(), 2, "{mixed:?}");
// A plain list: every entry weight 1. An entry list holding nothing is a no-op.
let plain = t
.decode(
json!([{"processor_type": "lithostitched:apply_random", "mode": "per_block",
"processor_lists": ["t:deepslate", []]}]),
)
.unwrap();
let seen: BTreeSet<String> = (0..10)
.flat_map(|p| outcomes(&plain, 9, [p, 0, 0]))
.collect();
assert_eq!(
seen,
BTreeSet::from(["minecraft:cobblestone".into(), "minecraft:deepslate".into()])
);
}
#[test]
fn set_block_runs_its_provider() {
let t = Lists::new(&[]);
let stairs = "minecraft:oak_stairs[facing=east,half=top]";
let warped = |preserve: Value| {
let mut p = set_block(simple("minecraft:warped_stairs"));
if !preserve.is_null() {
p["preserve_state"] = preserve;
}
t.decode(json!([p])).unwrap()
};
// preserve_state (true by default) keeps the properties both blocks have.
assert_eq!(
t.out(&warped(Value::Null), stairs),
"minecraft:warped_stairs[facing=east,half=top]"
);
assert_eq!(
t.out(&warped(json!(false)), stairs),
"minecraft:warped_stairs[facing=north,half=bottom]"
);
let weighted = t
.decode(json!([set_block(
json!({"type": "minecraft:weighted_state_provider", "entries": [
{"weight": 1, "data": {"id": "minecraft:blackstone"}},
{"weight": 3, "data": {"id": "minecraft:deepslate"}}]})
)]))
.unwrap();
let blackstone = (0..2000)
.filter(|i| {
let out = t.run(
&weighted,
(42, "minecraft:air"),
[0; 3],
[*i, 0, -*i],
"minecraft:cobblestone",
);
t.name(out) == "minecraft:blackstone"
})
.count();
assert!((420..580).contains(&blackstone), "{blackstone}");
// block_entity_modifier gives the output its block entity.
let mut loot = set_block(simple("minecraft:chest"));
loot["block_entity_modifier"] =
json!({"type": "minecraft:append_loot", "loot_table": "t:chests/house"});
let out = t
.run(
&t.decode(json!([loot])).unwrap(),
(0, "minecraft:air"),
[0; 3],
[0; 3],
"minecraft:air",
)
.unwrap();
let table = out
.nbt
.as_ref()
.and_then(|n| n.get(&JavaString::from("LootTable")));
assert_eq!(table, Some(&Tag::String("t:chests/house".into())));
// Providers the game does not run are refused.
assert!(
t.decode(json!([set_block(
json!({"type": "minecraft:noise_provider"})
)]))
.is_err()
);
}
}
@@ -1,434 +0,0 @@
//! Lithostitched's processors that pick processors, carried as data
//! (`horizon:apply_random` and `horizon:condition`): one runs a processor
//! list drawn by weight, the other one list or another by a test of the
//! block. Both draw from Lithostitched's RandomSettings: a random seeded from
//! the world seed and a name, at the block, the piece or the structure.
//! `horizon:set_block` is its `set_block`, which the conditions mostly run.
use super::{Processor, ProcessorList};
use crate::{
registry::{BlockRegistry, BlockStateId},
worldgen::{DecodeError, block_feature::FeatureBlockBindings, structure::StructureResources},
};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, PositionalRandom, RandomSource},
};
use serde_json::Value;
use std::{collections::BTreeSet, sync::Arc};
/// RandomSettings: where the random is taken, and the name salting it.
#[derive(Clone, Debug, PartialEq)]
pub struct RandomSettings {
mode: Mode,
/// ResourceLocation.hashCode of the name (`lithostitched:default`).
salt: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Mode {
Block,
Piece,
Structure,
}
impl RandomSettings {
/// A mode alone, or `{"mode": ..., "name": ...}`; per block when unset.
fn decode(v: Option<&Value>) -> Result<Self, DecodeError> {
let mode = Mode::decode;
let (mode, name) = match v {
None => (Mode::Block, "lithostitched:default"),
Some(Value::String(m)) => (mode(Some(m))?, "lithostitched:default"),
Some(o) => (
mode(o["mode"].as_str())?,
o["name"]
.as_str()
.ok_or(DecodeError::Field("random name"))?,
),
};
let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name));
Ok(Self {
mode,
salt: java_hash(namespace)
.wrapping_mul(31)
.wrapping_add(java_hash(path)),
})
}
/// RandomSource.create(seed + hash).forkPositional().at(the chosen
/// position): the piece's position, the structure's pivot or the block.
pub(super) fn create(
&self,
seed: i64,
piece: [i32; 3],
pivot: [i32; 3],
block: [i32; 3],
) -> LegacyRandom {
let [x, y, z] = self.mode.select(piece, pivot, block);
LegacyRandom::new(seed.wrapping_add(i64::from(self.salt)))
.fork_positional()
.at(BlockPos::new(x, y, z))
}
}
impl Mode {
fn decode(m: Option<&str>) -> Result<Self, DecodeError> {
match m {
None | Some("per_block") => Ok(Self::Block),
Some("per_piece") => Ok(Self::Piece),
Some("per_structure") => Ok(Self::Structure),
_ => Err(DecodeError::Field("random mode")),
}
}
fn select(self, piece: [i32; 3], pivot: [i32; 3], block: [i32; 3]) -> [i32; 3] {
match self {
Self::Block => block,
Self::Piece => piece,
Self::Structure => pivot,
}
}
}
/// SetBlockProcessor with a simple or weighted state provider and no block
/// entity change: the block becomes the state, keeping the properties it
/// shares with the old one unless `preserve_state` is off.
#[derive(Clone, Debug)]
pub struct SetBlock {
/// The states and their weights; one state alone is a simple provider,
/// which draws nothing.
states: Vec<(BlockStateId, i32)>,
weighted: bool,
preserve: bool,
mode: Mode,
}
impl SetBlock {
pub(super) fn decode(
v: &Value,
state: impl Fn(&Value) -> Result<BlockStateId, DecodeError>,
) -> Result<Self, DecodeError> {
let provider = &v["state_provider"];
let (states, weighted) = match provider["type"].as_str() {
Some("minecraft:simple_state_provider") => {
(vec![(state(&provider["state"])?, 1)], false)
}
Some("minecraft:weighted_state_provider") => (
provider["entries"]
.as_array()
.ok_or(DecodeError::Field("set_block entries"))?
.iter()
.map(|e| {
let weight = e["weight"]
.as_i64()
.ok_or(DecodeError::Field("set_block weight"))?;
Ok((state(&e["data"])?, weight as i32))
})
.collect::<Result<_, DecodeError>>()?,
true,
),
_ => return Err(DecodeError::Field("set_block state_provider")),
};
if states.is_empty() {
return Err(DecodeError::Field("set_block entries"));
}
Ok(Self {
states,
weighted,
preserve: v["preserve_state"].as_bool().unwrap_or(true),
mode: Mode::decode(v["random_mode"].as_str())?,
})
}
/// The state to set, before `preserve`: drawn with
/// RandomSource.create(seed).forkPositional().at(the chosen position).
pub(super) fn state(
&self,
seed: i64,
piece: [i32; 3],
pivot: [i32; 3],
block: [i32; 3],
) -> BlockStateId {
if !self.weighted {
return self.states[0].0;
}
let [x, y, z] = self.mode.select(piece, pivot, block);
let mut random = LegacyRandom::new(seed)
.fork_positional()
.at(BlockPos::new(x, y, z));
let total: i32 = self.states.iter().map(|s| s.1).sum();
let mut value = random.next_int_bound(total.max(1)).unwrap_or(0);
for (state, weight) in &self.states {
if value < *weight {
return *state;
}
value -= weight;
}
self.states[0].0
}
pub(super) fn preserves(&self) -> bool {
self.preserve
}
}
/// String.hashCode over UTF-16 units.
fn java_hash(s: &str) -> i32 {
s.encode_utf16()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
}
fn list(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<ProcessorList, DecodeError> {
ProcessorList::decode(&v.to_string(), resources, bindings)
}
/// ApplyRandomStructureProcessor: a weighted list of processor lists, one
/// drawn per block with `nextInt(total)`.
#[derive(Clone, Debug)]
pub struct ApplyRandom {
pub(super) random: RandomSettings,
lists: Vec<(ProcessorList, i32)>,
total: i32,
}
impl ApplyRandom {
pub(super) fn decode(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
let lists = v["processor_lists"]
.as_array()
.ok_or(DecodeError::Field("apply_random processor_lists"))?
.iter()
.map(|e| {
let weight = e["weight"]
.as_i64()
.ok_or(DecodeError::Field("apply_random weight"))?;
Ok((list(&e["data"], resources, bindings)?, weight as i32))
})
.collect::<Result<Vec<_>, DecodeError>>()?;
Ok(Self {
random: RandomSettings::decode(v.get("mode"))?,
total: lists.iter().map(|l| l.1).sum(),
lists,
})
}
/// WeightedList.getRandom: none when the weights add up to 0.
pub(super) fn pick(&self, random: &mut impl RandomSource) -> Option<&ProcessorList> {
if self.total <= 0 {
return None;
}
let mut value = random.next_int_bound(self.total).unwrap_or(0);
for (list, weight) in &self.lists {
if value < *weight {
return Some(list);
}
value -= weight;
}
None
}
}
/// ConditionProcessor: `then` when the condition holds for the block, else
/// `else`.
#[derive(Clone, Debug)]
pub struct Conditional {
pub(super) random: RandomSettings,
pub(super) condition: Condition,
pub(super) then: ProcessorList,
pub(super) otherwise: ProcessorList,
}
impl Conditional {
pub(super) fn decode(
v: &Value,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
) -> Result<Self, DecodeError> {
// A processor alone or a list of them.
let processors = |key: &str| -> Result<ProcessorList, DecodeError> {
match v.get(key) {
None => Ok(ProcessorList::default()),
Some(Value::Object(p)) if p.contains_key("processor_type") => Ok(ProcessorList(
vec![Processor::decode(&v[key], resources, bindings)?],
)),
Some(other) => list(other, resources, bindings),
}
};
Ok(Self {
random: RandomSettings::decode(v.get("random_mode"))?,
condition: Condition::decode(&v["if_true"], bindings)?,
then: processors("then")?,
otherwise: processors("else")?,
})
}
}
/// Lithostitched's processor conditions the game runs.
#[derive(Clone, Debug)]
pub(super) enum Condition {
True,
AllOf(Vec<Condition>),
AnyOf(Vec<Condition>),
Not(Box<Condition>),
RandomChance(f32),
/// The block placed (`input`) or the one in the world (`location`) is
/// one of these, with these property values.
MatchingBlocks {
blocks: Arc<BTreeSet<BlockStateId>>,
properties: Vec<(String, String)>,
location: bool,
},
}
impl Condition {
fn decode(v: &Value, bindings: &FeatureBlockBindings<'_>) -> Result<Self, DecodeError> {
let all = |a: &Value| -> Result<Vec<Condition>, DecodeError> {
a.as_array()
.ok_or(DecodeError::Field("processor conditions"))?
.iter()
.map(|c| Self::decode(c, bindings))
.collect()
};
if v.is_array() {
return Ok(Self::AllOf(all(v)?));
}
let kind = v["type"].as_str().unwrap_or_default();
Ok(match kind.strip_prefix("lithostitched:").unwrap_or(kind) {
"true" => Self::True,
"all_of" => Self::AllOf(all(&v["conditions"])?),
"any_of" => Self::AnyOf(all(&v["conditions"])?),
"not" => Self::Not(Box::new(Self::decode(&v["condition"], bindings)?)),
"random_chance" => Self::RandomChance(super::float(v, "chance")?),
"matching_blocks" => Self::MatchingBlocks {
blocks: super::holder_set(&v["blocks"], bindings)?,
properties: match v.get("properties") {
None => Vec::new(),
Some(p) => p
.as_object()
.ok_or(DecodeError::Field("matching_blocks properties"))?
.iter()
.map(|(k, x)| {
let x = x
.as_str()
.ok_or(DecodeError::Field("matching_blocks property"))?;
Ok((k.clone(), x.to_owned()))
})
.collect::<Result<_, DecodeError>>()?,
},
location: match v["match_type"].as_str() {
None | Some("input") => false,
Some("location") => true,
_ => return Err(DecodeError::Field("matching_blocks match_type")),
},
},
_ => return Err(DecodeError::Field("processor condition type")),
})
}
/// ProcessorCondition.test with the block placed and the one there.
pub(super) fn test(
&self,
input: BlockStateId,
location: BlockStateId,
registry: &BlockRegistry,
random: &mut impl RandomSource,
) -> bool {
match self {
Self::True => true,
Self::AllOf(all) => all
.iter()
.all(|c| c.test(input, location, registry, random)),
Self::AnyOf(any) => any
.iter()
.any(|c| c.test(input, location, registry, random)),
Self::Not(c) => !c.test(input, location, registry, random),
Self::RandomChance(chance) => random.next_float() < *chance,
Self::MatchingBlocks {
blocks,
properties,
location: at_location,
} => {
let state = if *at_location { location } else { input };
blocks.contains(&state)
&& properties.iter().all(|(k, x)| {
registry
.state(state)
.and_then(|s| s.properties.get(k))
.is_some_and(|v| v == x)
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_settings_salt_and_position() {
// ResourceLocation.hashCode: 31 * namespace + path.
let default = RandomSettings::decode(Some(&"per_piece".into())).unwrap();
assert_eq!(
default.salt,
java_hash("lithostitched")
.wrapping_mul(31)
.wrapping_add(java_hash("default"))
);
assert_eq!(java_hash("default"), 1544803905);
let (seed, piece, pivot, block) = (12345, [16, 64, -32], [8, 70, 8], [17, 65, -30]);
let mut ours = default.create(seed, piece, pivot, block);
let mut theirs = LegacyRandom::new(seed + i64::from(default.salt))
.fork_positional()
.at(BlockPos::new(16, 64, -32));
assert_eq!(ours.next_long(), theirs.next_long());
let named: Value = serde_json::json!({"mode": "per_structure", "name": "v:roof"});
let named = RandomSettings::decode(Some(&named)).unwrap();
assert_eq!(named.mode, Mode::Structure);
assert_eq!(
named.salt,
java_hash("v")
.wrapping_mul(31)
.wrapping_add(java_hash("roof"))
);
}
#[test]
fn apply_random_draws_by_weight() {
let apply = ApplyRandom {
random: RandomSettings {
mode: Mode::Block,
salt: 0,
},
lists: vec![
(ProcessorList(vec![Processor::Nop]), 3),
(ProcessorList::default(), 1),
],
total: 4,
};
let mut ours = LegacyRandom::new(9);
let mut theirs = LegacyRandom::new(9);
for _ in 0..32 {
let draw = theirs.next_int_bound(4).unwrap();
let picked = apply.pick(&mut ours).unwrap();
assert_eq!(picked.0.len(), usize::from(draw < 3));
}
}
#[test]
fn set_block_draws_from_the_unsalted_seed() {
let (a, b) = (BlockStateId(1), BlockStateId(2));
let set = SetBlock::decode(
&serde_json::json!({"state_provider": {"type": "minecraft:weighted_state_provider",
"entries": [{"weight": 1, "data": 1}, {"weight": 2, "data": 2}]},
"random_mode": "per_piece"}),
|v| Ok(BlockStateId(v.as_u64().unwrap() as u32)),
)
.unwrap();
assert!(set.preserves());
for x in 0..16 {
let mut java = LegacyRandom::new(77)
.fork_positional()
.at(BlockPos::new(x, 5, 3));
let want = if java.next_int_bound(3).unwrap() < 1 {
a
} else {
b
};
assert_eq!(set.state(77, [x, 5, 3], [0; 3], [9, 9, 9]), want);
}
}
}
+15 -8
View File
@@ -1,7 +1,7 @@
# The registry index and `.hpack`
Horizon's registry lists what is made for Horizon and reviewed: mods (`.hmod`), modpacks (`.hpack`), resource packs
and, once their format exists, Horizon shader packs. Submissions are pull requests in
and Horizon shader packs (`.hshader`, [shaders.md](../shaders.md)). Submissions are pull requests in
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods); merging one publishes it. The launcher and the
website read one file, `index.json`, which that repository's CI builds. The Rust types and every check below are
`horizon_mods::index` and `horizon_mods::modpack` (feature `index`); the sample is
@@ -21,7 +21,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
"license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...",
"versions": [{
"version": "0.1.0", "published": "2026-10-24", "changelog": "",
"api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "game": "26.3",
"api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "api_level": 0, "game": "26.3",
"file": { "name": "coords_hud-0.1.0.hmod", "url": "https://github.com/ludovic111/horizon-mods/releases/download/...",
"size": 438, "sha256": "e113...", "signature": "untrusted comment: ...\n...\ntrusted comment: ...\n...\n" }
}]
@@ -43,6 +43,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
| `versions` | Newest first, at least one. |
| `api` | A mod's API line (its manifest's `api`); a modpack's manifest format (`1`). |
| `side`, `capabilities` | A mod's, read from its manifest by CI, never typed by hand. |
| `api_level` | The mod API level a mod's code needs ([stability.md](stability.md#mod-api-levels)): its newest imported function's, read from the file by CI. Absent for content-only mods and in indexes built before levels. Installers take the newest version whose level the running game serves. |
| `game` | The Minecraft version it is for. |
| `requires` | A modpack's mods: `{ id, version, sha256 }`, each a version in this index with that hash. |
| `file` | A plain name with the kind's extension (`.hmod`, `.hpack`, `.zip`), an `https://` URL, the size (at most 64 MiB), the lowercase hex SHA-256, and the `.minisig` text when the project has a key. |
@@ -57,9 +58,11 @@ changing what one means, makes schema 2.
1. Its size and SHA-256 are the ones the entry lists, and it isn't revoked.
2. With a project key: its signature verifies with that key (minisign's prehashed and legacy signatures both).
3. What the file says matches the entry: a mod's `horizon-mod.toml` (id, version, API, side, capabilities) and its
3. What the file says matches the entry: a mod's `horizon-mod.toml` (id, version, API, side, capabilities), its API
level when the entry lists one, and its
content's fence ([mods.md](../mods.md#content-mods-api-02)); a modpack's manifest (id, version, format, game,
mods); a resource pack's `pack.mcmeta`.
mods); a resource pack's `pack.mcmeta`; a shader pack's `horizon_shader_api::check` and manifest (id, version,
shader API).
4. A kind this build can't install is refused with "this Horizon can't install a … yet".
The game still checks a mod's component imports against its capabilities when it starts it, as for any mod.
@@ -99,7 +102,7 @@ own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mo
```toml
id = "coords_hud" # the file's name, and the manifest's id
kind = "mod" # mod | modpack | resourcepack; shader once its format exists
kind = "mod" # mod | modpack | resourcepack | shader
name = "Coordinates HUD"
summary = "One line, at most 160 characters."
description = """Markdown.""" # optional
@@ -156,10 +159,14 @@ How the repository runs (its workflows are in `.github/workflows/`):
- **Taking a file down** is a pull request adding it to `revoked.toml`. Removing an entry outright (a test, a project
nobody installed) fails `registry review` on purpose and needs an administrator's merge.
Kinds open to submissions: `mod`, `modpack`, `resourcepack`. `shader` is in the schema and stays closed until
Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile
the pack in a headless Horizon. A new kind gets its own check before it opens.
Kinds open to submissions: `mod`, `modpack`, `resourcepack`, `shader`. A shader pack (`.hshader`) passes
`horizon_shader_api::check`, the check the game runs before it loads one (`horizon-shader check`, [shaders.md](../shaders.md)):
every stage validated by naga and translated for Vulkan, Metal and DirectX 12; its manifest's id, version and shader
API line are its facts. Iris and OptiFine packs are for the Modrinth browser. A new kind gets its own check before it
opens.
## Changelog
- 2026-10-26: the launcher installs `shader` files into `shaderpacks/`; the sample index lists one (`glow`).
- 2026-10-25: `shader` opens, checked by `horizon_shader_api::check`; its files are `.hshader`.
- Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1.
+95 -1
View File
@@ -1,7 +1,7 @@
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
# Mod API reference
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`.
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`, `0.3`.
## Capabilities
@@ -16,12 +16,22 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
| `render` | [`render`](#render) | draw on your screen, shake and zoom the camera |
| `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys |
| `players` | [`players`](#players) | see who is on the server and message them |
## World `horizon-mod`
A client mod: runs in the player's game.
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`, `command`
- Exports (what the mod provides): `events`
## World `horizon-server-mod`
A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs where the world is simulated, on the dedicated server, a LAN host and single player's own server.
- Imports (what the game offers): `log`, `storage`, `players`
- Exports (what the mod provides): `server-events`
## World `horizon-ui-mod`
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
@@ -390,6 +400,16 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `smooth` | `bool` | Ease in and out through the game's field-of-view easing, as the spyglass does, instead of at once. |
| `turn` | `f32` | Mouse turning is multiplied by it while zoomed: 1 keeps the player's sensitivity, at least 0.05. |
### record `minimap`
| Field | Type | |
| --- | --- | --- |
| `corner` | `corner` | |
| `size` | `f32` | Its side in GUI pixels, 16 to 256. |
| `blocks-per-pixel` | `f32` | World blocks per map pixel, 0.5 to 4; the map is 128 × 128 pixels. |
| `rotate` | `bool` | The map turns with the player, who faces up, and an N on its rim marks north. Otherwise north is up and the player's arrow turns. |
| `round` | `bool` | Round, the corners left clear; otherwise square. |
| Function | Signature | What it does |
| --- | --- | --- |
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
@@ -400,6 +420,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `text-width` | `func(text: string) -> f32` | The width of one line of text in GUI pixels, in the HUD's font: exact for Latin letters (U+0000 to U+024F), 6 per other character. |
| `frame-stats` | `func() -> frame-rate` | The game's frame counter, recomputed twice a second. |
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
## `events`
@@ -423,3 +444,76 @@ What the game calls. Only the hooks `init` asks for are ever called.
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
## `players`
Access: capability `players`.
Capability `players`, server mods only (manifest `api = "0.3"`, experimental): the players on the server, and messages to them.
### enum `game-mode`
| Case | |
| --- | --- |
| `survival` | |
| `creative` | |
| `adventure` | |
| `spectator` | |
### record `player`
| Field | Type | |
| --- | --- | --- |
| `id` | `string` | The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. |
| `name` | `string` | |
| `x` | `f64` | Feet position, in blocks. |
| `y` | `f64` | |
| `z` | `f64` | |
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
| `game-mode` | `game-mode` | |
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
| Function | Signature | What it does |
| --- | --- | --- |
| `online` | `func() -> list<player>` | The players online, in the order they joined. |
| `tell` | `func(player: string, text: string) -> bool` | A system message to one player, after the mod's name; false when no player with that id is online. Up to 8 messages per call. |
| `broadcast` | `func(text: string)` | A system message to every player and the server's log, after the mod's name. Counts toward the same 8 per call. |
## `server-events`
Access: exported by the mod: the game calls it.
What the server calls in a server mod (manifest `api = "0.3"`, experimental). Only the hooks `init` asks for are ever called. The player in each event is as the server saw them when it happened.
### type `player`
`type player = player`
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Commands players may type, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every server tick (20 per second). |
| `joins` | `bool` | Call `player-joined` and `player-left`. |
| `chat` | `bool` | Call `chat` for each chat message a player sends. |
| `blocks` | `bool` | Call `block-broken` and `block-placed`. |
### record `block-pos`
| Field | Type | |
| --- | --- | --- |
| `x` | `s32` | |
| `y` | `s32` | |
| `z` | `s32` | |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is logged. |
| `tick` | `func(tick: u64)` | Each server tick, with the server's tick counter. |
| `player-joined` | `func(player: player)` | A player joined the server; they are in `players.online` already. |
| `player-left` | `func(player: player)` | A player left the server; they are no longer in `players.online`. |
| `chat` | `func(player: player, text: string)` | A chat message a player sent, as plain text. |
| `command` | `func(player: player, name: string, args: string) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. |
| `block-broken` | `func(player: player, pos: block-pos, state: string)` | A player broke the block at `pos` in their dimension: `state` is what was there, as `minecraft:oak_log[axis=y]`. |
| `block-placed` | `func(player: player, pos: block-pos, state: string)` | A player placed `state` at `pos` in their dimension. |
+16 -11
View File
@@ -13,14 +13,14 @@ the player before the mod is turned on, and checked against the component's real
| | Count |
| --- | --- |
| Interfaces the game offers | 5: `log`, `game`, `chat`, `hud`, `storage` |
| Functions in them | 9 |
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat` |
| Capabilities | 5: `game-read`, `chat`, `hud`, `storage`, `registries` |
| Manifest `api` lines served | 2: `0.1` (code), `0.2` (content) |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers |
| Interfaces the game offers | 6: `log`, `game`, `chat`, `hud`, `storage`; `players` for server mods |
| Functions in them | 12 |
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat`; a server mod 8: `init`, `tick`, `player-joined`, `player-left`, `chat`, `command`, `block-broken`, `block-placed` |
| Capabilities | 6: `game-read`, `chat`, `hud`, `storage`, `registries`, `players` |
| Manifest `api` lines served | 3: `0.1` (code), `0.2` (content), `0.3` (server mods, experimental) |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (5 builders), `export_server_mod!` |
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
| Example mods | `coords-hud` (code), `ruby-content` (content) |
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
## Guide
@@ -49,6 +49,8 @@ call, 4 MiB of storage, fuel per call).
- `crates/horizon-mod-sdk/examples/coords-hud`: coordinates, facing and time on the HUD; `/where`, `/coords`; storage.
- `crates/horizon-mod-sdk/examples/ruby-content`: no code; a gem and a block of ruby from vanilla templates, with
their recipes, loot, tags, models, textures and names.
- `crates/horizon-mod-sdk/examples/welcome`: a server mod; greets players who join, announces newcomers (kept in
storage), `/online` and `/mined`.
- `crates/horizon-mods/tests/guest`: a mod that misbehaves on command (spins, panics, grows its memory), for the
sandbox tests.
@@ -78,15 +80,16 @@ scripting runtime in the game.
## Stability
Preview ([stability.md](stability.md)): API 0.1 and content 0.2 are served together; a mod built for 0.1.0 keeps
loading on every 0.1.x game. Server mods come as their own line, `api = "0.3"`, experimental first.
loading on every 0.1.x game. Server mods are their own line, `api = "0.3"`, experimental: they may change in any
release.
## Not there yet
What real mods need next, in the order modpacks and servers need it:
- **Server mods** (`api = "0.3"`): the same component running on the server thread and the dedicated server, with world events (block
broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed
arguments.
- **Server mods in the game** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): the
contract, runtime and SDK are here; next the server thread, LAN host and dedicated server start them and call
their hooks, then entity events, game rules and server commands with typed arguments.
- **World and blocks**: read blocks, block states and biomes in loaded chunks; change them through the server's block
log, so clients and the parity harnesses see vanilla's updates.
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
@@ -102,6 +105,8 @@ What real mods need next, in the order modpacks and servers need it:
## Changelog
- API 0.3 (server mods, experimental): world `horizon-server-mod`, capability `players`, the `server-events`
hooks; SDK `server::ServerMod`; example `welcome`.
- SDK: `cargo horizon-mod test` and `horizon_mods::testing`, test scripts in TOML; the template has one.
- Experimental: `ui` button widget; `render.set-corner`, `text-width`, `frame-stats`; `input.mapping`,
`clicks-per-second` (what the official FPS, keystrokes and zoom mods need).
+3 -2
View File
@@ -91,11 +91,12 @@ pack exist ([stability.md](stability.md)).
## Not there yet
The stages and everything that runs a pack (the format above is read and checked). The order: the renderer hooks shared with the format loader, then the material and post
stages with options and hot reload, then the overlay, lighting, sky and shadow stages.
The material stage in the game (the sky and post stages run, with hot reload), option values other than the defaults,
then the overlay, lighting and shadow stages.
## Changelog
- 2026-10-26: the sky stage in the game: the pack's `sky` gets vanilla's colour along each ray.
- 2026-10-26: the post stage in the game (`HORIZON_SHADER_PACK`, hot reload with the last good pack kept).
- 2026-10-25: `horizon-shader` (`new`, `check`, `pack`), three templates, the example pack and the guide.
- 2026-10-25: the check (naga validation, signatures, options, budgets, SPIR-V/Metal/HLSL translation).
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more