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 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 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 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 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 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 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 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 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 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
140 changed files with 6897 additions and 2003 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
@@ -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]
+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);
}
}
+8 -70
View File
@@ -378,12 +378,6 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
Minimap,
MinimapCorner,
MinimapSize,
MinimapShape,
MinimapZoom,
MinimapRotate,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -402,13 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Minimap"),
Ok(Minimap),
Ok(MinimapCorner),
Ok(MinimapSize),
Ok(MinimapShape),
Ok(MinimapZoom),
Ok(MinimapRotate),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -418,32 +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::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",
@@ -458,10 +424,9 @@ impl Item {
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
@@ -474,12 +439,6 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
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."
}
@@ -511,7 +470,7 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright => Some(Cost::None),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
@@ -521,8 +480,6 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
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,
@@ -537,38 +494,22 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
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 sort 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::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,
@@ -712,12 +653,9 @@ mod tests {
#[test]
fn items_cycle_through_their_values() {
let mut e = Extras::default();
Item::MinimapCorner.cycle(&mut e, true);
assert_eq!(e.minimap_corner, Corner::TopLeft);
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
+13 -2
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;
@@ -2271,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.
@@ -4193,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() {
@@ -5600,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",
@@ -5639,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 {
+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();
@@ -219,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.
@@ -378,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);
+62
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]))?;
@@ -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]) {
+10 -25
View File
@@ -1,5 +1,5 @@
//! Horizon's extras in the game (`docs/extras.md`): the minimap's tiles and
//! image, and its HUD; a mod's zoom (`render.set-zoom`) on the camera; clicks
//! 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};
@@ -128,44 +128,29 @@ impl Game<'_> {
}
Ok(())
}
/// The minimap that shows, the extra's or else a mod's
/// (`render.set-minimap`): its corner, its side in GUI pixels and its
/// view of the player.
/// 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()?;
if self.title.is_some() || self.ui.hide_gui {
return None;
}
let e = &self.settings.extras;
let (corner, size, rotate, blocks_per_pixel, round) = if e.minimap {
let size = f32::from(e.minimap_size);
(
e.minimap_corner,
size,
e.minimap_rotate,
e.minimap_zoom,
e.minimap_round,
)
} else {
let m = self.mod_minimap()?;
(m.corner, m.size, m.rotate, m.blocks_per_pixel, m.round)
};
let m = self.mod_minimap()?;
let view = View {
x: p.position.x,
z: p.position.z,
yaw: self.yaw,
rotate,
blocks_per_pixel,
round,
rotate: m.rotate,
blocks_per_pixel: m.blocks_per_pixel,
round: m.round,
};
Some((corner, size, view))
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_shown() else {
let wanted = self.settings.extras.minimap || self.mod_minimap().is_some();
if !wanted || self.player.is_none() {
if self.mod_minimap().is_none() || self.player.is_none() {
self.extras.minimap.clear();
self.extras.minimap_dim = None;
}
+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.
+72 -58
View File
@@ -1192,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) => {
@@ -1831,6 +1835,10 @@ 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();
@@ -1916,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);
}
}
@@ -2176,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 {
+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;
}
+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.
@@ -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) {
+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),
}
+99 -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(());
};
@@ -3616,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 {
@@ -4066,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);
@@ -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(
+118 -47
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,7 @@ 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.
@@ -2414,7 +2417,7 @@ 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,
@@ -2924,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
@@ -3003,20 +3011,20 @@ impl Renderer {
/// 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_post::NativePost>,
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(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_post) {
let timer = match (&self.pack_post, &self.native) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(post)) => post.timing.as_ref(),
(_, Some(native)) => native.timing.as_ref(),
_ => None,
};
timer
@@ -4046,8 +4054,8 @@ 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();
if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native_post)
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
@@ -4109,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,
},
})];
@@ -4149,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);
@@ -4505,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);
@@ -4688,7 +4759,7 @@ 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_post) {
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 {
@@ -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()
}
}
+104 -70
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,12 +26,87 @@ 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>)>,
start: std::time::Instant,
frames: u32,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
}
/// 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 {
@@ -63,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),
@@ -95,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),
@@ -145,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),
@@ -162,15 +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,
timing: None,
})
}
@@ -264,37 +313,18 @@ 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) = &mut self.timing {
t.begin(device, queue);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
@@ -317,7 +347,7 @@ impl NativePost {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(label)),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
@@ -437,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;
@@ -478,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:?}");
}
}
}
}
@@ -128,6 +128,11 @@ impl PassTiming {
}
}
/// 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)?;
@@ -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)
}
+48 -16
View File
@@ -25,7 +25,7 @@ pub struct Moved {
pub name: &'static str,
}
pub const MOVED: [Moved; 3] = [
pub const MOVED: [Moved; 4] = [
Moved {
id: "fps_overlay",
name: "FPS counter",
@@ -38,6 +38,10 @@ pub const MOVED: [Moved; 3] = [
id: "zoom",
name: "Zoom",
},
Moved {
id: "minimap",
name: "Minimap",
},
];
impl Moved {
@@ -45,7 +49,8 @@ impl Moved {
match self.id {
"fps_overlay" => e.fps,
"keystrokes" => e.keystrokes,
_ => e.zoom,
"zoom" => e.zoom,
_ => e.minimap,
}
}
/// The extra's settings as the mod keeps them (its storage keys).
@@ -60,11 +65,21 @@ impl Moved {
("corner", e.keystrokes_corner.name().into()),
("cps", flag(e.keystrokes_cps)),
],
_ => vec![
"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
@@ -149,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));
@@ -208,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(), 3);
assert_eq!(nothing_to_do(&e).len(), 4);
e.fps = true;
e.fps_corner = Corner::BottomRight;
e.fps_lows = true;
@@ -223,7 +238,7 @@ mod tests {
);
e.moved.push("fps_overlay".into());
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2]]);
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;
@@ -240,6 +255,23 @@ mod tests {
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"
+78 -15
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 => {}
@@ -3315,7 +3377,7 @@ mod tests {
s.extras
);
// In a window too short for the page, the wheel scrolls to its end.
let short = (480.0, 300.0);
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);
@@ -4066,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;
};
};
}
+88
View File
@@ -412,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;
@@ -422,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";
+314 -79
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::{
@@ -101,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`].
@@ -115,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 },
@@ -136,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),
}
@@ -165,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.
@@ -221,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 {
@@ -245,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);
@@ -276,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)
@@ -299,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
@@ -311,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()?;
@@ -327,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("."))
@@ -357,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);
@@ -373,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
}
@@ -397,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() {
@@ -410,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>(
@@ -457,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 }),
@@ -521,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
@@ -544,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 {
@@ -781,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;
}
@@ -890,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`).
+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",
+155 -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(
@@ -731,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]
@@ -770,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]
@@ -807,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"
);
}
+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.
+84 -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.
@@ -434,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).
+17 -1
View File
@@ -34,6 +34,22 @@ Not breaking: a new function, interface, capability, command, endpoint, tool or
argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier
don't import it); faster, or fixing behaviour to what the reference already says.
### Mod API levels
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`. A mod needs the level of
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
never declared:
- the game refuses a mod that needs a function it doesn't have, with "made for a newer Horizon (it uses
render.set-minimap): update Horizon to run it", instead of a linking error;
- the registry's CI writes each mod version's level into the index (`api_level`), and installers take the newest
version the running game can run.
A pull request that adds a function lists it in `api_level.rs` at the next level and raises `LEVEL`; the
`api_reference` test fails until it does.
## Stability levels
Every part of an API has one level, shown in its reference and its surface page.
@@ -92,7 +108,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Mod API: `render` (HUD shapes, camera shake and zoom; drawn by the game, no mod shaders) | experimental | manifest `experimental = ["render"]` |
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
| Server mods: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
| Shader API | not started | |
+1 -1
View File
@@ -51,7 +51,7 @@ file selects ~103.
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
| 36 | Audio and music (38) | `L/audio.rs`, `L/sound_engine.rs`, `L/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e --server-thread` (portals, levels, distance, lava) |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
| 38 | Command parsing (34) | `L/command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`) | command_parse (corpus `tools/command_parse_corpus.py`) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `tools/perf_bench.py`, `L/telemetry.rs`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
+14 -19
View File
@@ -1,6 +1,6 @@
# Horizon extras
Optional conveniences built into the client: fullbright, dynamic lights, a minimap, auto-reconnect,
Optional conveniences built into the client: fullbright, dynamic lights, auto-reconnect,
Mouse Tweaks-style inventory controls and inventory sorting. **Every one is off by default**: a fresh
install looks and behaves exactly like vanilla 26.3, and turning an extra off
again restores vanilla's behaviour completely.
@@ -12,8 +12,8 @@ again restores vanilla's behaviour completely.
| Settings, parsing and saving | `crates/horizon-client/src/extras.rs` |
| FPS and click counters (for mods) | `extras.rs` (pure logic, unit tested) |
| Dynamic lights: item glow, nearest sources | `crates/horizon-client/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) |
| HUD drawing (minimap frame, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
| Minimap colours, tiles and image | `crates/horizon-client/src/minimap.rs` |
| HUD drawing (a mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
| Minimap colours, tiles and image (for `render.set-minimap`) | `crates/horizon-client/src/minimap.rs` |
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` |
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` |
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
@@ -62,7 +62,7 @@ in Controls, and adding one would put a non-vanilla mapping into `options.txt`.
### Moved to the mod browser
The **FPS counter**, **Keystrokes** and **Zoom** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)),
The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)),
with the same look and options. On the first start after the update, a player who had one on gets the mod installed
from Horizon's registry and turned on, with their corner and options carried over (Zoom's key too, into
`horizon-mods.txt`, when it wasn't Z), and reads one line in the chat ("Zoom moved to the mod browser: installed for
@@ -71,19 +71,14 @@ SHA-256, signature, manifest); if it fails (offline), the next start tries again
keys and lists what was done in `movedToMods`. Code: `crates/horizon-client/src/moved_extras.rs`,
`game/moved_extras.rs`.
### Minimap (`minimap`, default off)
- A corner map (top right by default), square or round, small/medium/large,
zoom 0.5 to 4 blocks per pixel, rotating with the player or north up, with the
player arrow (vanilla's map decoration sprite).
- Colours are vanilla's `MapColor` per block, as map items use, with the map
item's height shading and water-depth dithering.
- Underground, in the Nether and under any roof, it maps the floor around the
player's height instead of the top block, so caves read sensibly.
- Cost: one cached 16x16 tile per loaded chunk (packed map colour and height).
Tiles are filled nearest first and refreshed when blocks change, within a time
budget of 0.3 ms a frame; composing the 128x128 image is a few thousand
lookups, done only when the view moved or a tile changed, and uploaded into a
dedicated atlas slot. With the minimap off nothing runs.
The game still draws the minimap, for the mod (`render.set-minimap`, [mods.md](mods.md)):
- a corner map (top right by default), square or round, small/medium/large, zoom 0.5 to 4 blocks per pixel, rotating
with the player or north up, with the player arrow (vanilla's map decoration sprite);
- vanilla's `MapColor` per block, as map items use, with the map item's height shading and water-depth dithering;
- underground, in the Nether and under any roof, the floor around the player's height instead of the top block;
- one cached 16x16 tile per loaded chunk (packed map colour and height), filled nearest first and refreshed when
blocks change within 0.3 ms a frame; the 128x128 image is recomposed only when the view moved or a tile changed, and
uploaded into a dedicated atlas slot. With no mod showing a map, nothing runs.
### Auto-reconnect (`autoReconnect`, default off)
- On the "Connection lost" screen after a multiplayer connection drops, a
@@ -140,11 +135,11 @@ and the container screen tests are unchanged).
the world, like vanilla's HUD. Extras in the top left corner give way to the
debug screen (F3), which shows the FPS anyway.
- Corners stack: extras placed in the same corner stack downward (top corners)
or upward (bottom corners) in the order minimap, then mods' panels. The top
or upward (bottom corners) in the order a mod's minimap, then mods' panels. The top
right corner starts below the status effect icons.
## Perf budget
Off: a flag check per frame per extra (no allocations, no world access).
On: the minimap stays under 0.3 ms of CPU
A mod's minimap stays under 0.3 ms of CPU
per frame (tile budget) plus a 64 KiB texture upload when its image changes.
+1 -1
View File
@@ -94,7 +94,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `respawn` | `respawn` | Respawn from the death screen | none |
| `pause` | `pause` | Open the pause menu | none |
| `resume` | `resume` | Leave the pause menu | none |
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |
+1 -1
View File
@@ -321,7 +321,7 @@ Search for packs Horizon loads.
horizon-launcher mods search [TEXT]...
- `TEXT`
- `--kind` (mod, resource, modpack)
- `--kind` (mod, resource, modpack, shader)
- `--everything`: Also what was made for other versions or runs only partly, each marked
- `--source` (modrinth, curseforge, horizon)
+10 -6
View File
@@ -1,7 +1,7 @@
# The launcher's mod browser
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks, resource packs and shader
packs, see how well each one runs before installing it, and install it with its checksum checked. This is item 16 of the roadmap and issue #577. The code is in
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks and resource packs, and Horizon's
registry for Horizon's own mods, modpacks, resource packs and shader packs; see how well each one runs before installing it, and install it with its checksum checked. This is item 16 of the roadmap and issue #577. The code is in
`launcher/src/browser.rs` and `launcher/src/browser/`.
## What it offers
@@ -16,6 +16,7 @@ yet (Modrinth has no `horizon` loader).
| Java mods (Fabric, NeoForge, Forge, Quilt) | ported into `horizon-mods/<mod id>/` and turned on | Checked from its jar before install: **Works**, **Partly** or **Doesn't run** ([below](#java-mods)) |
| Resource packs | `resourcepacks/` beside `options.txt` | **Works** when made for 26.3 ([resource-packs.md](resource-packs.md) lists what Horizon reads). Older packs are **Partly**: they load with vanilla's compatibility warning |
| Modpacks (`.mrpack`, CurseForge zip) | the Modpacks page's import | **Partly**: Horizon runs the mods it can port, from the player's own downloads ([modpacks.md](modpacks.md)) |
| Shader packs (`.hshader`, Horizon's registry only) | `shaderpacks/` beside `options.txt` | **Works** when made for 26.3: checked by the game's own check before install ([shaders.md](shaders.md)). The `shaders/`-format packs on Modrinth and CurseForge are not offered |
By default a search shows only what runs: Java mods made for 26.3, resource packs made for 26.3, and every modpack. **Show everything** lists the rest of the kind too, each marked with how well it runs; what doesn't run is
never offered for install.
@@ -74,7 +75,8 @@ start); the hedge places and its recipe crafts 4.
## The Mods page
The launcher's **Mods** page has the three kinds as tabs (Mods, Modpacks, Resource packs), a search field (Enter searches), **Show everything** and
The launcher's **Mods** page has the four kinds as tabs (Mods, Modpacks, Resource packs, Shader packs; the last
searches Horizon's registry), a search field (Enter searches), **Show everything** and
the source: Modrinth, with **CurseForge (coming soon)** beside it, disabled. Each result is an index row: its summary, author,
downloads, source and licence, how well it runs (and why), **Page** (the project's page on its source) and
**Install** (**Import** for a modpack). The Modpacks tab lists the imported packs first, with their coverage
@@ -125,7 +127,8 @@ same name the player put there is never overwritten.
- **Install** picks the newest version that runs, then its required dependencies, checked before any download: a
dependency that isn't a pack Horizon loads (a Java mod) stops the install. Dependencies go in first. A resource
pack still has to be turned on in the game's Resource Packs screen, as in vanilla.
pack still has to be turned on in the game's Resource Packs screen, as in vanilla, and a shader pack picked in
the game's Shader Packs page.
- **Update** installs the newer version and removes the old file.
- **Remove** deletes the file and its entry. Packs installed as dependencies stay.
- **A modpack** is downloaded (checked the same way) into `ports/downloads/`, imported, and ported straight away
@@ -149,7 +152,7 @@ horizon-launcher mods remove faithful-32x
## Sources
**Horizon's registry.** Mods (`.hmod`) and resource packs made for Horizon, reviewed as pull requests in
**Horizon's registry.** Mods (`.hmod`), modpacks (`.hpack`), resource packs and shader packs (`.hshader`) made for Horizon, reviewed as pull requests in
[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) ([api/mod-index.md](api/mod-index.md)). The
launcher reads its `index.json` (`browser/horizon.rs`; `HORIZON_MOD_INDEX=<file or https address>` reads a test
registry instead, whose files may then be on disk) at most every ten minutes, and keeps the last copy in
@@ -164,7 +167,8 @@ checked pack, `pack.json` and `home/`, the profile's game folder, where each of
like any registry mod and turned on or off as the pack says; its overrides (settings, resource packs, a mod's saved
settings) are copied in on the first install only, so the player's changes survive updates. A pack whose mod was taken
down installs nothing. It is listed on the Modpacks tab with **Play** and **Remove**, and plays like an imported pack
(`modpacks::apply_profile`). Shader packs are listed by the index but not installed by this launcher yet.
(`modpacks::apply_profile`). A shader pack goes to `shaderpacks/<file>` once `check_file` has run the game's own
check on it (`horizon_shader_api::check`); a taken-down one is named, for the player to remove.
```sh
horizon-launcher mods search --source horizon --kind mod coords # with whether it is signed and what it may do
+16 -15
View File
@@ -504,21 +504,22 @@ Two cases the oracle fills in:
mod. Better Dungeons' small dungeons keep one chest and each other one with a fifth's
chance, two at most (the game's `horizon:count_cap`, counted per placement as the mod's
DungeonContext is): 12 chests in five small dungeons at seed 1234 become 6.
Lithostitched's `block_swap` (137 lists, all in ChoiceTheorem's Overhauled Village) is the
game's `horizon:block_swap`: the same map, with Block.withPropertiesOf's properties kept;
a target block the game does not have (most of them are Nature's Spirit's and Croptopia's)
leaves the block as it is, as the mod does. Lithostitched's `reference` (CTOV builds most
of its lists from others) has the referenced lists' processors written in its place; its
`apply_random`, `condition` and `set_block` are the game's `horizon:apply_random`,
`horizon:condition` and `horizon:set_block`, with Lithostitched's random (the world seed
plus the name's hash, at the block, piece or structure). In a CTOV plains village at seed
1234 the farms now grow carrots, potatoes and beetroot besides wheat, and 1,655
cobblestone blocks become paths, grass, mossy cobblestone and planks. No Lithostitched processor is
dropped now (130 before). CTOV's compat hooks for other mods inside them (its
`modular_compat`, 142 copies) run a list of their own only when the mod they name is
loaded: carry writes that list in when the mod is ported along and takes the hook out (a
no-op, as in the mod) when it is not. In ATM10 all 142 name Nature's Spirit, which the
pack does not have.
Lithostitched's processors (all in ChoiceTheorem's Overhauled Village in ATM10) are the
game's own types, read as the mod writes them. `block_swap` keeps the properties the new
block can have: a farm's ripe wheat becomes ripe carrots, but beetroot, whose age stops at
3, keeps its default; a target block the game does not have (most are Nature's Spirit's and
Croptopia's) leaves the block as it is. `reference` runs the lists it names, `condition`
tests the template's block or the world's, `apply_random` and `set_block` draw per block or
per piece as the data says. Lithostitched's own random is not known yet, so the shares of a
random choice are the Java pack's but the blocks chosen differ. Lists named by tag and state
providers other than simple and weighted ones are dropped and counted (ATM10 has none).
CTOV's compat hooks for other mods (its `modular_compat`, 30 of them in 17 lists) become a
`reference` when the mod they name is ported along, and are taken out (a no-op, as in the
mod) when it is not. In ATM10 they all name Nature's Spirit, which the pack does not have.
In Java, Lithostitched also lays out every jigsaw structure its own way (villages,
outposts, trial chambers; CTOV's villages are `lithostitched:jigsaw`, carried as vanilla's
jigsaw). Horizon keeps vanilla's layouts, so with Lithostitched in a pack, structure layouts
differ from the Java pack's for the same seed (their positions do not).
The processors that build outside their template are carried too: Repurposed's and
Moog's pillar processors become the game's `horizon:pillar` (a column from a placeholder
block down to the ground, with the processor list they run on each of its blocks written
+54 -3
View File
@@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom |
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap |
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
@@ -120,7 +120,7 @@ loading. Ordered by what the first native ports need:
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
| `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints |
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
| `server` | world events (block break/place, entity spawn/death, player join), game rules, server commands | runs where the world is simulated |
| `server` | world events (entity spawn/death), game rules, world read/write, typed server commands | after [server mods 0.3](#server-mods-api-03-experimental)'s players, chat, blocks and commands |
| `net` | a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
There is no file, socket, process or raw-input interface, on purpose.
@@ -251,7 +251,8 @@ key> --signature <file>.minisig`.
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
the sandbox the game uses, with no game and no window: the test plays the
game's part (the player and the clock the mod reads, ticks, frames, chat lines,
commands) and checks what the mod showed. A script fails at its first wrong
commands; for a server mod, players joining, chatting, typing commands,
breaking and placing blocks and leaving) and checks what the mod showed. A script fails at its first wrong
step, with what it expected and what it got; the command fails if any script
does. `--debug` builds without optimizing, `--hmod <file>` tests a `.hmod`
already built, and a name runs only the scripts whose path holds it. Each
@@ -288,6 +289,16 @@ A step's parts happen in this order:
| `ticks = n`, `frames = n` | that many ticks (the game's tick counter and the clock move on), then frames 1/60 s apart |
| `chat = "<Steve> hi"` | a chat line the player receives |
| `command = "where north"` | the player types `/where north`; a command the mod doesn't own fails the step |
| `break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }`, `place = { ... }` | server mods: the player breaks or places that block |
| `leave = "Alex"` | server mods: that player leaves |
A server mod starts with nobody on: `join = "Alex"` (after `reload` and `title-screen`) brings a
player in at the defaults, in survival, with the id
`00000000-0000-4000-8000-000000000001` for the first name that joined, `...2`
for the second, the same id when a name joins again. `player`, `chat`,
`command`, `break` and `place` are then that player's doing: the one named by
`as = "Steve"`, or the first one on. `player` takes `game-mode` there, and the
script's own `[player]` and `[clock]` are a client mod's.
Then `expect` checks what the mod did during the step; a key left out is not
checked:
@@ -298,6 +309,7 @@ checked:
| `chat = ["..."]` | every chat line it showed, in order (`[]`: none) |
| `feedback = "..."`, `error = "..."` | the command's answer, shown plain or in red |
| `panel = ["..."]`, `corner = "top-left"` | its HUD panel's lines (`[]`: no panel) and corner |
| `told = ["Alex: Welcome!"]`, `broadcast = ["..."]` | server mods: every `players.tell` (as `name: text`) and `players.broadcast`, in order |
| `stopped = "panic"` | the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test |
Unknown keys are errors, so a typo never passes silently. The same runner is
@@ -452,6 +464,38 @@ none.
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
```
### Server mods (API 0.3, experimental)
A server mod runs where the world is simulated: on the dedicated server, on a
LAN host and on single player's own server, never in a player's window. Its
manifest says `side = "server"` and `api = "0.3"` (and only a server mod may);
its component targets the WIT world `horizon-server-mod` instead of
`horizon-mod`. Experimental: it may change in any release
([stability](api/stability.md)).
| Interface | Capability | Functions |
|---|---|---|
| `log` | always | as for client mods |
| `storage` | `storage` | as for client mods, in the server's `horizon-mods/` |
| `players` | `players` | `online()`: id, name, position, dimension, game mode, health of each player on; `tell(id, text)`, `broadcast(text)`: system messages after the mod's name, 8 per call |
| Hook (export `server-events`) | When |
|---|---|
| `init() -> result<registration, string>` | once, after loading |
| `tick(tick)` | each server tick |
| `player-joined(player)`, `player-left(player)` | `registration.joins` |
| `chat(player, text)` | `registration.chat`: each chat message a player sends |
| `command(player, name, args)` | `/name args` typed by a player, for a command it registered; `ok(some)` is that player's feedback, `err` their red error |
| `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` |
Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for
client mods; `players` is for server mods. In Rust: `impl
horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs). The
runtime, the registry's `check` and the SDK run them today; the game starts
them once its server code calls these hooks (the next step), and until then a
server mod that is turned on shows as run elsewhere.
## Publish your mod
Horizon's registry lists mods, modpacks (`.hpack`) and resource packs made for Horizon, each reviewed before it is
@@ -490,6 +534,7 @@ so the same rustc and `Cargo.lock` give the same file.
| FPS Counter | `fps_overlay` | Extras > FPS counter | `corner`: `top_left` (default), `top_right`, `bottom_left`, `bottom_right`; `lows`: `false` (default), `true` |
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
## Versions and compatibility
@@ -500,6 +545,12 @@ so the same rustc and `Cargo.lock` give the same file.
release cycle, so existing mods keep working while authors move.
- The manifest's `api` names the line it needs (`0.1`); the game refuses a
line it does not serve with a clear message.
- Within a line, each function has an API level, the one it arrived at
([stability](api/stability.md#mod-api-levels)). A mod needs the level of
the newest function it calls, read from its file: an older Horizon refuses
it with "made for a newer Horizon: update Horizon", and the registry lists
the level so installers take a version the game can run.
`cargo horizon-mod check` prints it ("needs mod API level 1 or newer").
- Horizon follows Minecraft releases; the mod API does not have to. Vanilla
concepts mods see (dimensions, block and item ids) are the game's own ids,
so a version port changes the data, not the API.
+50
View File
@@ -111,3 +111,53 @@ and shrieker. They supplement the deterministic component comparisons.
## Reading vanilla
Besides the Vineflower sources in `.local/minecraft/<version>/sources/`, `tools/rea_vanilla.py` queries the shipped bytecode through REA (callers through lambdas, overloads, a second decompiler); see [decompilation status](decompilation-status.md#querying-the-shipped-bytecode-with-rea).
## Player-facing integration and deterministic fixtures
`python3 tools/lan_e2e.py` runs every LAN check with an integrated server thread,
the same path as normal single player. `python3 tools/test_friends.py --client
target/fast/horizon-client --relay target/fast/horizon-relay --baseline` covers
plain LAN, direct invitations, a local relay and ended invitations in that mode.
Both accept `--in-frame` for comparison and retain `--server-thread` for existing
callers. Run Linux games under `xvfb-run -a` when no display is available. Each
process gets scratch game, launcher, XDG data and config homes; telemetry, audio
and launcher self-update are disabled.
`lan_integration` and `friends_integration` register these suites with `parity.py`.
`friends_integration` (under a minute) is selected like any harness when the
drivers or game implementation change. `lan_integration` is the whole LAN suite
(~40 minutes on a loaded runner, and single checks flake under load), so
`tools/parity_hosts.json` lists it under `named_only`: no automatic selection
(`--changed`, `--failed`, `--flake-check`, CI, parity-main) picks it; it runs
when named and in the nightly audit (`--all`). LAN writes `results.json`
with the host mode and each result beside its screenshots and logs. A skipped
case is recorded explicitly and does not count as passing coverage.
The game-binary fixture audit keeps these complementary direct rule tests:
- `command_harness.rs`: command feedback, styled spans and result callbacks,
held player position, explicit ticks and portal completion matching
`VanillaCommandExecOracle`. LAN `commands`, `feedback`, `styles`, `levels` and
`portals` cover command input, packet feedback and travel across the boundary.
- `dispense_fixture.rs`: seeded synthetic worlds, block/event traces, item
use/mining and combat hooks matching `VanillaDispenserOracle`. These isolate
rule results and RNG order; LAN machines, crafting, clicks, fireball, mining
and combat exercise real input and server handling separately. The new
`dispenser_input_host` / `dispenser_input_guest` checks press a button and
verify authoritative inventory consumption and exactly one drop on both clients.
- `sculk_fixture.rs`: scripted events and exact per-tick sculk/warden traces
matching `VanillaSculkOracle`; introducing wall-clock network ticks would
destroy that deterministic oracle contract. `sculk_input_host` and
`sculk_input_guest` separately exercise real footsteps reaching a sensor;
failures remain visible rather than changing the oracle trace.
- `net_harness.rs`: already a real network client of a dedicated server; keep
its handshake, world loading, chat and command packet assertions.
Library probes, `lan_bot`'s protocol hub fixture, generation/save tests and
streaming/mesh tests remain fast deterministic coverage. They do not become
full game launches. Setup and authoritative reads use the owning server;
interactions stay on the client. `server-hold on|off` provides a prediction-test
barrier between server ticks, including player packet handling, while transport
threads and control calls remain responsive. `stall MILLISECONDS` stalls only
the window to test recovery from a hung frame without process signals interrupting
socket reads. Neither mechanism changes normal gameplay.
+14 -11
View File
@@ -13,8 +13,9 @@ Horizon's own format.
| Authoring tool | `horizon-shader` (same crate) | `new`, `check`, `pack` |
| Templates | `crates/horizon-shader-api/templates/` | post, sky, material |
| Example pack | `crates/horizon-shader-api/packs/golden_hour` | a warm sky and two post passes |
| In the game: the post stage | `HORIZON_SHADER_PACK=<name>` (for development, until the Shader Packs page) | post passes, hot reload |
| In the game: sky and material; the launcher and the registry | | not yet |
| In the game: the sky and post stages | Options > Horizon Settings > Shader Packs | sky, post passes, hot reload |
| The registry and the launcher | `shader` kind ([api/mod-index.md](api/mod-index.md)); the Mods page's Shader packs tab | checked, then installed into `shaderpacks/` |
| In the game: the material stage | | not yet |
## Quick start
@@ -46,12 +47,14 @@ my_pack 0.1.0 (shader API 0.1): FAILED
## Trying a pack in the game
Until the Shader Packs page lists them, put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt`
and start the game with `HORIZON_SHADER_PACK=<its folder or file name>`. The game checks the pack first, and refuses
one that fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
Put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt` (Open Pack Folder opens it) and pick
it on Options > Horizon Settings > Shader Packs, which lists Horizon's packs, marked (Horizon), beside packs in the
`shaders/` format; one runs at a time, and clicking it again turns it off. `HORIZON_SHADER_PACK=<its folder or file
name>` runs a pack whatever the page says, for development. The game checks the pack first, and refuses one that
fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
and the pack is checked and rebuilt in place ("Shader pack ... reloaded"); if the new version fails, the chat says
where and the last good version keeps drawing. The post passes run today; the sky and material stages are checked
but not drawn yet.
where and the last good version keeps drawing. The sky and post stages run today; the material stage is checked but
not drawn yet.
## A pack
@@ -86,7 +89,7 @@ by Horizon: a pack changes how things look, not how they are drawn.
| Stage | Your function | What it does |
| --- | --- | --- |
| `[sky]` | `fn sky(input: SkyInput) -> vec4<f32>` | the sky's colour along each view ray; the sun, moon, stars and clouds are drawn over it |
| `[sky]` | `fn sky(input: SkyInput) -> vec4<f32>` | the sky's colour along each view ray, opaque (alpha is ignored), drawn where vanilla draws its sky dome (the Overworld's sky; not the Nether's or the End's); the sun, moon, stars and clouds are drawn over it |
| `[material]` | `fn material(input: MaterialInput) -> Surface` | the lit colour of each point of a block's surface, before the alpha test and fog |
| `[[post]]` | `fn post(input: PostInput) -> vec4<f32>` | a full-screen pass over the world's picture (up to 8, in order), under the hand and HUD |
@@ -129,9 +132,9 @@ one keeps drawing), the launcher before it installs one, and the registry's CI b
`[budget] gpu_ms` is the GPU time your pack aims to add to a frame. To compare, open F3 in the game: while it is
open, each pass is timed on the GPU and the overlay shows the pack's total and its costliest passes, by their `name`
(the median of the last 15 timed frames). Timing waits for nothing, so it does not slow the game.
(the median of the last 15 timed frames). Timing waits for nothing, so it does not slow the game. In a timed frame the sky
stage draws in a pass of its own (it shares the world's pass otherwise), so its time includes clearing the picture.
## Next
Options > Shader packs listing these packs, then the material and sky stages, and the registry's `shader` kind with
installs from the launcher.
The material stage in the game.
+111 -7
View File
@@ -565,7 +565,16 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec 0.9.1",
]
[[package]]
@@ -574,6 +583,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -611,7 +626,7 @@ dependencies = [
"ash-window",
"bitflags 2.13.2",
"bytemuck",
"codespan-reporting",
"codespan-reporting 0.12.0",
"glow",
"gpu-alloc",
"gpu-alloc-ash",
@@ -621,7 +636,7 @@ dependencies = [
"libloading",
"log",
"mint",
"naga",
"naga 25.0.1",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -1037,6 +1052,15 @@ dependencies = [
"unicode-width",
]
[[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 = "color_quant"
version = "1.1.0"
@@ -2295,7 +2319,7 @@ dependencies = [
"log",
"lyon",
"metal",
"naga",
"naga 25.0.1",
"num_cpus",
"objc",
"oo7",
@@ -2560,6 +2584,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]]
name = "heck"
@@ -2700,12 +2729,14 @@ dependencies = [
name = "horizon-mods"
version = "0.1.0"
dependencies = [
"horizon-shader-api",
"log",
"minisign-verify",
"serde",
"serde_json",
"sha2 0.10.9",
"toml 1.1.8+spec-1.1.0",
"wasmparser",
"zip",
]
@@ -2742,6 +2773,19 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga 30.0.1",
"serde",
"serde_json",
"sha2 0.10.9",
"toml 1.1.8+spec-1.1.0",
"zip",
]
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
@@ -3543,10 +3587,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
dependencies = [
"arrayvec",
"bit-set",
"bit-set 0.8.0",
"bitflags 2.13.2",
"cfg_aliases",
"codespan-reporting",
"codespan-reporting 0.12.0",
"half",
"hashbrown 0.15.5",
"hexf-parse",
@@ -3555,12 +3599,50 @@ dependencies = [
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
"spirv",
"spirv 0.3.0+sdk-1.3.268.0",
"strum 0.26.3",
"thiserror 2.0.21",
"unicode-ident",
]
[[package]]
name = "naga"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"codespan-reporting 0.13.1",
"half",
"hashbrown 0.17.1",
"indexmap",
"libm",
"log",
"naga-types",
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
"spirv 0.4.0+sdk-1.4.341.0",
"thiserror 2.0.21",
"unicode-ident",
]
[[package]]
name = "naga-types"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash 1.1.0",
"thiserror 2.0.21",
]
[[package]]
name = "nanorand"
version = "0.7.0"
@@ -5405,6 +5487,15 @@ dependencies = [
"bitflags 2.13.2",
]
[[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 2.13.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -6516,6 +6607,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
dependencies = [
"bitflags 2.13.2",
"hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more