Compare commits

...
Author SHA1 Message Date
Ludovic Marie ce46917121 Merge pull request #2056 from ludovic111/feat/mod-minimap-api
🤖 Agent: Mod API render.set-minimap: a mod picks the corner, size, scale, shape and turning, and the game draws the map from the minimap extra's tiles. It is a new function, so published mods keep loading. Verified: pr_ready (1353 tests, clippy, launcher), the sandbox test, and in game beside the published mods in both modes.
2026-10-26 09:12:15 +01:00
Ludovic Marie 09d61ec866 Merge pull request #2030 from ludovic111/feat/moved-zoom
🤖 Agent: Zoom moved to the mod browser. The built-in zoom is removed, and a player who had it on gets the published Zoom mod with their settings and key. Verified against the real registry, in both modes (54/54), plus clippy and the client tests.
2026-10-26 09:09:19 +01:00
Ludovic Marie 2b54c8a4ab Merge pull request #1812 from ludovic111/fix/trapped-chest-signal-barrel-open
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:57:39 +01:00
Ludovic Marie ff26716cd1 Merge pull request #1809 from ludovic111/fix/portal-effect-on-server-clients
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:55:30 +01:00
Ludovic MarieandClaude Opus 5.5 641ff51089 Merge main into feat/mod-minimap-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:55:02 +01:00
Ludovic Marie 45bd296f14 Merge pull request #1896 from ludovic111/fix/creative-drop-on-server
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:53:22 +01:00
Ludovic Marie d31832eaf4 Merge pull request #1996 from ludovic111/perf/reach-planning
🤖 Agent: merged main in. The old head's parity failure was server_dimension_players, which passes on main's parity-main. tests/test_parity_reach.py OK.
2026-10-26 08:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 e52f0fdcc2 Merge main into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:39:51 +01:00
Ludovic Marie 3f5a323fd9 Merge pull request #2026 from ludovic111/feat/shader-pass-times
🤖 Agent: verified: pr_ready --quick green, 32/32 affected harnesses pass locally, CI verify green, paired A/B shows no frame cost without a pack. F3 now shows a shader pack's GPU time per pass, for both pack kinds.
2026-10-26 08:33:05 +01:00
Ludovic MarieandClaude Opus 5.5 24ab642e7b Mod API: render.set-minimap, a map the game keeps in a corner
What the official Minimap mod needs: a mod picks the corner, side, scale,
shape and turning, and the game draws the map as the minimap extra does,
from the same tiles (vanilla's map colours, the floor under roofs) within
its own frame budget. One map at a time, the first loaded mod's. A new
function and type, so mods built before it keep loading.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:11:46 +01:00
Ludovic Marie 301b006ef4 Merge pull request #1816 from ludovic111/feat/mod-test-runner
🤖 Agent: cargo horizon-mod test runs a mod's test scripts with no game. Main is merged in, and the runner fills the new snapshot fields (input, render, frame stats) with the trial run's screen size. Verified: clippy, the cargo-horizon-mod, coords-hud and horizon-mods tests (57), launcher, and cargo horizon-mod test on coords-hud (2 scripts).
2026-10-26 08:07:39 +01:00
Ludovic Marie 2d57f97869 Merge pull request #2033 from ludovic111/perf/mob-ai-tick
🤖 Agent: verified: parity for the 75 harnesses parity.py picks passes locally, pr_ready --quick passes on the final head, lan_e2e mobs/hazards pass in-frame and on the server thread. Same results; −1.3% of the dedicated server's tick-thread cycles at 10 bots.
2026-10-26 08:00:18 +01:00
Ludovic MarieandClaude Opus 5.5 8e71cb4754 Test runner: fill the snapshot's new fields, with the trial run's screen size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:57:49 +01:00
Ludovic Marie c3b29a2652 Merge pull request #2023 from ludovic111/feat/official-zoom-mod
🤖 Agent: The official Zoom mod (mods/official/zoom): the zoom extra as a mod, pixel-identical and at the same frame cost. Verified with main merged: clippy, cargo horizon-mod check, and in game in both modes. Publishing follows in horizon-mods.
2026-10-26 07:57:29 +01:00
Ludovic Marie 3163c7867d Merge pull request #1995 from ludovic111/feat/moved-hud-extras
🤖 Agent: The FPS counter and Keystrokes moved to the mod browser. A player who had one on gets the published official mod installed on the first start, with their settings and a chat line. Verified against the real registry, in both modes (34/34), plus clippy and the client tests.
2026-10-26 07:55:07 +01:00
Ludovic MarieandClaude Opus 5.5 f7d7c7c70a Merge main into feat/mod-test-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:47:58 +01:00
Ludovic Marie 7cb390fbff Merge pull request #2032 from ludovic111/fix/ctl-options-set-mods
🤖 Agent: horizon-ctl options-set no longer turns every mod off: Settings::with_option keeps the mod list with Horizon's other files. Verified: unit test, clippy, client tests, and in game in both modes (10/10).
2026-10-26 07:29:59 +01:00
Ludovic MarieandClaude Opus 5.5 c8d75a7892 Merge main into feat/official-zoom-mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 828a2519e1 Merge main into feat/moved-hud-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:23:36 +01:00
Ludovic MarieandClaude Opus 5.5 053291e339 Mob scans skip sections that can't hold what they look for
A mob's fluid state and its supporting block each read every cell around
its box, every tick. World::may_hold answers from the palettes of the
sections the box reaches: with no fluid listed there the fluid scan stops
before reading a cell, and with no block that reaches out of its cell
(large collision shapes, moving pistons) the supporting block search
reads only the cells meeting its area, as BlockCollisions' cursor does.
Results are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:22:42 +01:00
Ludovic MarieandClaude Opus 5.5 130f1dc6a5 horizon-ctl options-set keeps the mod list
options-set rebuilt Settings from options.txt and carried over Horizon's
graphics, controller and extras files, but not the mod list: every mod
read as off afterwards, and the next save wrote an empty
horizon-mods.txt. Settings::with_option now does the rebuild and keeps
all four.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:13:21 +01:00
Ludovic Marie ee6dfd2b6c Merge pull request #2016 from ludovic111/feat/mod-ui-key
🤖 Agent: Players rebind a mod's keys on its Options > Mods page (the game's own key rows, saved in horizon-mods.txt), with no WIT type change, so the published mods keep loading. Verified with main merged: clippy, the client, mods and SDK tests, and the in-game check in both modes (24/24).
2026-10-26 06:50:11 +01:00
Ludovic MarieandClaude Sonnet 5.5 c1814283e0 Merge origin/main into fix/creative-drop-on-server
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:49:26 +01:00
Ludovic MarieandClaude Sonnet 5.5 4512105e85 Merge origin/main into fix/trapped-chest-signal-barrel-open
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Opus 5.5 c3763ff1b3 F3 shows the shader pack's GPU time per pass, for either kind of pack
While the F3 overlay is open, the running pack's passes are timed on the GPU,
a shaders/ pack's or a native pack's, and the overlay lists the pack's total
and its costliest passes (the median of the last 15 timed frames). The timer
never waits: a timed frame's stamps are resolved once the GPU has finished it
and read back once mapped, then the next frame is timed. A native pack's first
pass counts from a mark that ends with the world's draws, so it does not carry
the time it waited for them. HORIZON_SHADER_PACK_TIMING still logs the medians.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 0958cb297a Merge origin/main into fix/portal-effect-on-server-clients
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:55 +01:00
Ludovic Marie 5bcc87983e Merge pull request #1861 from ludovic111/feat/native-shaders-post
🤖 Agent: native shader packs' post stage in the game with hot reload; pr_ready, 66 affected harnesses and a paired A/B (no cost without a pack) passed.
2026-10-26 06:45:33 +01:00
Ludovic MarieandClaude Opus 5.5 a6c8e9070a Zoom moved to the mod browser: remove the built-in zoom
The official Zoom mod (mods/official/zoom) does what the extra did. The
extra's zoom, its key and wheel hooks and its Extras rows go; the camera
keeps a mod's zoom (render.set-zoom). A player who had zoom on gets the
mod installed and turned on at the first start, with the starting zoom,
smooth zoom and slower turning carried into its storage and a changed
key into horizon-mods.txt, and one chat line. horizon-extras.txt keeps
the old keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:42:14 +01:00
Ludovic Marie 344ef96894 Merge pull request #2025 from ludovic111/ci/parity-main-bisect-confirm
🤖 Agent: bisect fix; tests/test_parity_main.py passes (4 tests).
2026-10-26 06:20:14 +01:00
Ludovic Marie 2f46365df1 Merge remote-tracking branch 'origin/main' into feat/mod-ui-key 2026-10-26 06:15:25 +01:00
Ludovic Marie 886ecd9b20 Merge remote-tracking branch 'origin/main' into perf/reach-planning 2026-10-26 06:13:02 +01:00
Ludovic MarieandClaude Opus 5.5 2c5dac354e parity-main: name a merge only when HEAD fails again and the base passes
The bisect ended on the newest merge when the failed harnesses pass
everywhere now (a flake, or a runner that was missing the sounds), and
on the oldest when they fail everywhere (the runner), and commented on
that pull request either way. It now reruns them at HEAD and at the base
first and names nobody unless HEAD fails and the base passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:11:01 +01:00
Ludovic MarieandClaude Opus 5.5 bea3165267 Merge main into feat/mod-test-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:11 +01:00
Ludovic MarieandClaude Opus 5.5 90a9d9c612 Merge main into feat/moved-hud-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:02:48 +01:00
Ludovic Marie 7ecb43ccc8 Merge pull request #1982 from ludovic111/feat/official-hud-mods
🤖 Agent: Horizon's official FPS Counter and Keystrokes as mods in mods/official/, same options and look as the extras (pixel-identical, frame cost within noise). Main merged in (wasmtime 48). Verified: pr_ready, clippy and tests of both mods, horizon-mods tests, both mods' settings pages in game.
2026-10-26 06:02:08 +01:00
Ludovic MarieandClaude Opus 5.5 d113131f7e Merge feat/official-zoom-mod into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:55:10 +01:00
Ludovic MarieandClaude Opus 5.5 82deb28954 Merge main into feat/mod-ui-key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:54:27 +01:00
Ludovic MarieandClaude Opus 5.5 8fdb27f949 Zoom: the game lists its key on the settings page, as "Zoom key: Z"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:47:15 +01:00
Ludovic Marie e372fce7ab Merge branch 'feat/mod-ui-key' into feat/official-zoom-mod 2026-10-26 05:35:16 +01:00
Ludovic MarieandClaude Opus 5.5 1aa6f21620 Horizon shader packs: the post stage runs in the game, with hot reload
A native pack named by HORIZON_SHADER_PACK (a folder with horizon-shaders.toml
or a .hshader in shaderpacks/) is checked, then its post passes run over the
world's own target, the one a shaders/ pack reads: no second target and no
extra pass, and without a pack the frame only tests one Option. The pack is
looked at twice a second; a saved file is checked and rebuilt in place, and a
version that fails is refused in the chat while the last good one keeps
drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:35:00 +01:00
Ludovic MarieandClaude Opus 5.5 3e3c0cebb5 Mod keys: rows the game adds to the settings page, not a WIT widget
A new case in ui's widget variant stops every ui mod built before it from
loading: the runtime links a component only when each imported
function's types match exactly ("expected variant of 5 cases, found
4"), so the published FPS Counter and Keystrokes would break. Instead,
the game lists a mod's keys first on its page in Options > Mods, as Key
Binds rows, and a mod with keys gets a page even without ui. No WIT
type changes. docs/api/stability.md said a new case the mod sends isn't
breaking; it is, and now says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:34:49 +01:00
Ludovic Marie 8cf51888c6 Merge pull request #1757 from ludovic111/feat/structure-saved-starts
🤖 Agent: structures stack brought up to date with main (merge commits); queued with verify passed, the merger waits for parity on this head.
2026-10-26 05:22:52 +01:00
Ludovic Marie b485c43928 Merge pull request #2017 from ludovic111/ci/vanilla-data-new-shared-entries
🤖 Agent: CI script fix, tested on scratch folders. The release runner had no sounds, so parity-main's audio harnesses failed.
2026-10-26 05:18:40 +01:00
Ludovic Marie d8aaf367eb Merge pull request #2013 from ludovic111/feat/mod-api-zoom
🤖 Agent: Mod API (experimental): render.set-zoom and input.claim-wheel, held mod keys let go when a screen opens, mod keys on mouse buttons. The consume choice is in stability.md. Verified: pr_ready (1300 tests), sandbox tests, in game 22/22 in-frame and on the server thread.
2026-10-26 05:12:07 +01:00
Ludovic MarieandClaude Opus 5.5 5335354658 CI: runners clone what the shared source gained after their first copy
The release runner, now parity-main's, cloned the shared source before
the sounds were added to it; vanilla-data.sh only copied on the first
run, so ambient_sound, audio_attributes and music failed there with
"Sound assets missing". Each run now clones any entry the runner's copy
lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:10:19 +01:00
Ludovic MarieandClaude Opus 5.5 0cb1d46393 Merge main into feat/official-hud-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:09:45 +01:00
Ludovic MarieandClaude Opus 5.5 53289d3565 Official mod: Zoom, the zoom extra as a mod
mods/official/zoom: hold the key (Z) to zoom in at the starting zoom,
the wheel changes it while held (x1.25 a step, 1.25 to 10), smooth
easing and slower turning as options. Same settings page as the extra,
with the key widget for the key. Built on render.set-zoom,
input.claim-wheel and ui's key widget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:08:36 +01:00
Ludovic Marie 88d061ec9b Merge pull request #1955 from rdbtCVS/t3/migrate-parity-server-thread
🤖 Agent: Thanks rdbt! The command, dispenser and sculk fixtures now run on a server Game. Checked:
2026-10-26 05:06:41 +01:00
Ludovic Marie 4231905b1e Merge pull request #1967 from socksprox/fix/mannequin-npcs
🤖 Agent: Thanks socksprox! Mannequins render as the player model with their profile skin. I pushed d3af1e04e: a mannequin is a LivingEntity, not a Mob, so it runs no goals and never despawns. Checked in game: a summoned mannequin stays put for 400 ticks on the server thread and falls to the ground in-frame (it wandered 14 blocks before). Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 05:00:59 +01:00
Ludovic Marie 1114b4d734 Merge pull request #1933 from levzzz5154/fix/item-cooldowns
🤖 Agent: Thanks levzzz5154! Matches ItemCooldowns and the slot sweep in Gui.renderSlot; thrown, eat and shield pass on both hosts. I merged main and dropped orphaned doc comments. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:58:42 +01:00
Ludovic Marie c31e8b2381 Merge pull request #1932 from levzzz5154/fix/chat-visibility
🤖 Agent: Thanks levzzz5154! Matches ChatComponent's ChatVisiblity filter for the Full, Commands only and Hidden options. I pushed 1e6f05ed5 earlier: every line keeps its source, so changing the option refilters the history both ways (new test). Verify had failed because main's mod output (#1875) calls Chat::add without a source. I merged main, and 2256941af marks a mod's lines as the client's own, which the Chat option keeps. Clippy and the chat tests pass after the merge. Before it, the wave 2 batch passed its 127 affected harnesses, and lan_e2e chat passed with the host in-frame and on its server thread.
2026-10-26 04:54:32 +01:00
Ludovic Marie 2e05b11a4d Merge remote-tracking branch 'origin/feat/official-hud-mods' into feat/official-zoom-mod 2026-10-26 04:53:36 +01:00
Ludovic Marie 23d4bc0a69 Merge pull request #1905 from rdbtCVS/fix/server-player-sculk-events
🤖 Agent: Thanks rdbt! Every server player now emits its own movement game events, with per-player sculk history; the new server_player_sculk harness passes. I merged main (the guest swap code). Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:52:23 +01:00
Ludovic Marie f1ab726503 Merge pull request #1897 from vlesmentie/fix/pointing-hand-cursor
🤖 Agent: Thanks vlesmentie! Matches vanilla 26.3's AbstractWidget.handleCursor: buttons show the pointing hand. lan_e2e's cursor check passes. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:49:09 +01:00
Ludovic Marie 061ae61465 Merge pull request #1963 from ludovic111/feat/mod-api-extras
🤖 Agent: Mod API calls the official HUD mods use: render corner panels, text width and frame stats; input mappings and clicks per second; a ui button widget. Main merged in (after #1914's widget refactor). Verified: horizon-mods tests, clippy with and without mods, horizon-client tests (1121 lib), Keystrokes' settings page in game.
2026-10-26 04:45:01 +01:00
Ludovic Marie 5320391ff6 Merge pull request #1904 from rdbtCVS/fix/remote-fireball-motion
🤖 Agent: Thanks rdbt! Matches the client's LargeFireball tick between tracker updates; the new remote_fireball harness passes and lan_e2e's fireball check sees it on both sides. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:42:27 +01:00
Ludovic MarieandClaude Opus 5.5 7e4012c83e Mod API: a key widget, so players bind a mod's keys (ui, input)
A ui page's key widget shows one of the mod's keys as Key Binds does
("Zoom: Z"). A click waits for the next key or mouse button, which
becomes the key; Esc binds none. The game keeps the choice in
horizon-mods.txt (key.<mod>.<name>:key.keyboard.z), applies it when
the mods load and to keys registered later, and lets a held key go when
it is rebound. register-key's key is now the default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:41:12 +01:00
Ludovic Marie c94c4e140f Merge pull request #1915 from vlesmentie/fix/big-dripleaf-tilt
🤖 Agent: Thanks vlesmentie! Matches BigDripleafBlock.entityInside and canEntityTilt (the 0.6875 height check): a big dripleaf tilts under a player standing on it. Tested with the wave 2 batch: fmt, clippy and the horizon-client and horizon-world tests pass, and all 127 affected harnesses pass. The chunk_generation and decorated_region reruns took 8 minutes with no chunk differing. lan_e2e contacts and the rest of wave 2's checks pass with the host in-frame and on its server thread.
2026-10-26 04:39:41 +01:00
Ludovic Marie 3d8cf1f53a Merge pull request #1710 from ludovic111/fix/remote-tnt
🤖 Agent: main merged into the branch (merge commit); verify passed on this head, the merger waits for parity.
2026-10-26 04:36:46 +01:00
Ludovic Marie 5a79c8dede Merge pull request #1997 from ludovic111/fix/server-ops-feedback-save-wait
🤖 Agent: ready. server_ops_feedback waits for vanilla's and Horizon's 'Saved the game' before the save step's 2 s, so a loaded runner (the desktop's full trial) no longer cuts the transcript. Harness only. verify passed; parity ran server_ops_feedback on the Mac: PASS 120 s.
2026-10-26 04:34:33 +01:00
Ludovic Marie 2979303263 Merge pull request #1977 from ludovic111/perf/collision-scan-edges
🤖 Agent: requeued after a runner-setup parity failure (subtitle: missing sounds.json on the runner); 30/31 harnesses passed, the 56 selected passed locally. Queued by perf-next-2.
2026-10-26 04:33:56 +01:00
Ludovic Marie ce266e60be Merge pull request #1741 from ludovic111/feat/styled-menu-titles
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:31:44 +01:00
Ludovic Marie e410a3977b Merge pull request #1735 from ludovic111/feat/server-dialog-inputs
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:29:32 +01:00
Ludovic Marie d177a96be6 Merge pull request #1916 from ludovic111/feat/shader-packs-shadow-casters
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:27:16 +01:00
Ludovic Marie 452c444933 Merge pull request #1918 from vlesmentie/fix/water-cauldron-extinguish
🤖 Agent: Thanks vlesmentie! Matches LayeredCauldronBlock.entityInside / handleEntityOnFireInside: a burning player is put out and the cauldron loses a level. The cauldron harness passes, and lava and contacts pass on both hosts. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:24:50 +01:00
Ludovic Marie a4eefb0730 Merge remote-tracking branch 'origin/main' into cg15/1955
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 04:24:12 +01:00
Ludovic Marie f72a63ca01 Merge pull request #1869 from ludovic111/fix/prepare-rewritten-version-file
🤖 Agent: verified: the fix for #1772 plus #1947 merged in. Its parity job prepares with this preparer and checks that the output is still OUTPUT_VERSION 1, so with #1946 players keep their prepared resources. Merging.
2026-10-26 04:24:12 +01:00
Ludovic MarieandClaude Opus 5.5 2256941af7 Chat: mod output lines are the client's own
Main's mod output calls Chat::add without the new source; a mod's line is
written by this client, so the Chat option keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:23:25 +01:00
Ludovic Marie 6e15257873 Merge remote-tracking branch 'origin/main' into cg15/1932
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-26 04:23:24 +01:00
Ludovic Marie 1d83584da4 Merge pull request #1900 from ludovic111/feat/shader-packs-shadow-entities
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:22:09 +01:00
Ludovic Marie 8d6754226e Merge pull request #1934 from socksprox/feat/player-head-skins
🤖 Agent: Thanks socksprox! Player heads now show their owner's skin, fetched like vanilla's SkullBlockRenderer from the profile's textures property; TEXTURE_URIS limits it to textures.minecraft.net, so there's no new host.
2026-10-26 04:19:47 +01:00
Ludovic Marie 2ae2e57c66 Merge pull request #1449 from ludovic111/chore/wasmtime-48
🤖 Agent: main merged into the branch (merge commit); verify passed on this head.
2026-10-26 04:19:14 +01:00
Ludovic MarieandClaude Opus 5.5 de294e34bb rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:19:07 +01:00
Ludovic Marie fed2bdb304 Merge pull request #1892 from vlesmentie/fix/chicken-flap-remote
🤖 Agent: Thanks vlesmentie! Matches Chicken.aiStep's flap on the client of a server. I merged main and slimmed the test to reuse an existing mob kind. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:17:55 +01:00
Ludovic Marie 4aca4e151a Merge pull request #1734 from ludovic111/feat/server-dialog-screen
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:17:30 +01:00
Ludovic Marie 1faafb9f90 Merge pull request #1888 from ludovic111/feat/shader-packs-shadow-terrain
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:16:44 +01:00
Ludovic Marie 6bbf40e345 Merge pull request #1753 from ludovic111/feat/structure-blocks-harness
🤖 Agent: structures stack brought up to date with main (merge commits), verify and parity passed on this head.
2026-10-26 04:13:52 +01:00
Ludovic Marie 4d460bfb3a Merge pull request #1887 from ludovic111/feat/shader-packs-shadow-buffers
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:10:49 +01:00
Ludovic MarieandClaude Opus 5.5 c89434557b Player heads without a skin show slim steve, as vanilla
SkullBlockRenderer's SKIN_BY_TYPE is DefaultPlayerSkin.getDefaultTexture(),
DEFAULT_SKINS[6] = entity/player/slim/steve; the block_entity_render harness
found Horizon drawing nothing for heads without a profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:09:25 +01:00
Ludovic Marie d208121c8e Merge pull request #1886 from ludovic111/feat/shader-packs-shadow-programs
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:08:13 +01:00
Ludovic MarieandClaude Sonnet 5.5 55c961b889 Merge origin/main into fix/remote-tnt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 04:07:18 +01:00
Ludovic Marie 1880e12f2b Merge pull request #1885 from ludovic111/feat/shader-packs-shadow-camera
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:05:36 +01:00
Ludovic MarieandClaude Sonnet 5.5 7d4792fb31 Mods API reference test: follow wit-parser 0.254 (FixedLengthList, Param)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 04:03:03 +01:00
Ludovic Marie 163f099ecb Merge pull request #2006 from ludovic111/perf/behaviour-cache-hash-once
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body); the duplicate 70 MB hash at world entry removed. Queued by perf-next-2.
2026-10-26 03:41:58 +01:00
Ludovic Marie 6d92727d9d Merge pull request #2004 from ludovic111/perf/sculk-ticking-chunks
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body); tick_sculk_blocks halved at 10 bots, −75% at 25. Queued by perf-next-2.
2026-10-26 03:38:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 b4700c0a9d Merge feat/structure-blocks-harness into feat/structure-saved-starts
Both branches changed how the harness finds the mod jars: resolve every jar
through jar_path (jars, then pack-jars) and keep the profile packs check.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:36:24 +01:00
Ludovic Marie 2b4e1fd03b Merge pull request #1944 from ludovic111/feat/mod-input
🤖 Agent: Mod API capability input (experimental): claimed items' use and the mod's keys. Main merged in beside command and render (9 capabilities). Verified: horizon-mods tests (sandbox, API reference), clippy with and without mods, horizon-client tests (1121 lib), earlier pr_ready and in-game runs in both modes.
2026-10-26 03:35:25 +01:00
Ludovic MarieandClaude Sonnet 5.5 355df65240 Merge feat/yung-port-as-written into feat/structure-blocks-harness
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:31:39 +01:00
Ludovic Marie c7c4cfe996 Merge pull request #1801 from ludovic111/feat/shader-packs-hand
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:30:48 +01:00
Ludovic Marie ef4018805e Merge pull request #2008 from ludovic111/ci/parity-main-vanilla-data
🤖 Agent: verify passed at 0c84eb307. Merged by hand: the PR parity job is queued behind the same runners, and this changes only how that job prepares its data. The next PR parity job on ludovics-mac-mini is the real test.
2026-10-26 03:27:46 +01:00
Ludovic Marie 905464e2b3 Merge pull request #2005 from ludovic111/perf/rings-out-of-reach
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body, incl. decorated_region's stronghold site and world_spawn); first chunk 8.7 → 6.0 s on 4 threads. Queued by perf-next-2.
2026-10-26 03:27:25 +01:00
Ludovic Marie 6c2effae82 Merge pull request #1931 from levzzz5154/fix/draggable-scrollbars
🤖 Agent: Thanks levzzz5154! The Game Rules and Statistics scroll bars can be pressed, dragged and released like vanilla's; I merged main and pushed a41381236 (Statistics uses the GUI height; #1913's drag test is folded in). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:24:52 +01:00
Ludovic MarieandClaude Sonnet 5.5 d322c4b870 Merge feat/server-dialog-screen into feat/styled-menu-titles
Both branches changed the machine screens' title drawing: keep the styled server
title from this branch and the translated container title and inventory label
from feat/server-dialog-screen.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:22:13 +01:00
Ludovic Marie 4bfb7f6506 Merge pull request #1965 from rdbtCVS/t3/optimize-launcher-resource-prepare
🤖 Agent: Thanks rdbt, preparation needs only a Java runtime now and runs 5 JVMs instead of 18! Verified on the Mac mini:
2026-10-26 03:21:56 +01:00
Ludovic Marie ba105e0784 Merge branch 'feat/official-hud-mods' into feat/moved-hud-extras 2026-10-26 03:20:09 +01:00
Ludovic MarieandClaude Opus 5.5 980c619b81 Merge feat/mod-api-extras (with main) into feat/official-hud-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:09 +01:00
Ludovic Marie 9f42b39aca Merge pull request #2002 from ludovic111/ci/parity-main-own-runner
🤖 Agent: verify passed at 2c612fd79. The PR parity job is still queued behind the same runners, and it can't test a runs-on change, so I merge by hand.
2026-10-26 03:19:47 +01:00
Ludovic Marie 1e317cff5e Merge branch 'feat/mod-api-extras' into feat/mod-api-zoom 2026-10-26 03:19:44 +01:00
Ludovic Marie 0f47d2a028 Merge pull request #1800 from ludovic111/feat/shader-packs-held-items
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:19:36 +01:00
Ludovic MarieandClaude Sonnet 5.5 0c73bb3c90 Merge feat/server-dialog-screen into feat/server-dialog-inputs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:18:45 +01:00
Ludovic MarieandClaude Opus 5.5 0c84eb3079 ci: PR parity jobs prepare vanilla data with main's script
A PR head older than the fix in vanilla-data.sh left a real
.local/horizon-client without sounds.json on runner ludovics-mac-mini;
every later job there failed subtitle. Like the harness selection, the
job now runs main's script unless the branch changes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:18:39 +01:00
Ludovic Marie 666a1ba6f7 Merge pull request #1786 from ludovic111/feat/shader-packs-weather
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:16:38 +01:00
Ludovic MarieandClaude Sonnet 5.5 a1ffa1ac11 Merge feat/server-dialogs into feat/server-dialog-screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:14:19 +01:00
Ludovic MarieandClaude Opus 5.5 50c54098ef Merge feat/mod-input (with main) into feat/mod-api-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:13:27 +01:00
Ludovic Marie 9bf96bcea5 Merge pull request #1785 from ludovic111/feat/shader-packs-clouds
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:12:49 +01:00
Ludovic Marie 4457d4e0ca Merge remote-tracking branch 'origin/main' into perf/behaviour-cache-hash-once 2026-10-26 03:12:43 +01:00
Ludovic Marie 96290f4b94 Merge remote-tracking branch 'origin/main' into perf/rings-out-of-reach 2026-10-26 03:12:31 +01:00
Ludovic Marie facc1d4d08 Merge remote-tracking branch 'origin/main' into perf/sculk-ticking-chunks 2026-10-26 03:12:20 +01:00
Ludovic Marie 8dfe518b60 Merge pull request #1911 from vlesmentie/fix/structure-chest-enchantments
🤖 Agent: Thanks vlesmentie! Structure chests keep the enchantments their loot table rolled. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:11:16 +01:00
Ludovic MarieandClaude Opus 5.5 2c612fd791 ci: parity-main on the release runner, not behind PR parity jobs
The queued parity-main run waited 2 h behind PR parity jobs on the two
horizon-game runners, each merge replacing the pending one, and releases
need it green. The release runner gets a horizon-parity-main label; a
release only starts after parity-main passed, so the two never compete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:10:55 +01:00
Ludovic MarieandClaude Opus 5.5 48db367d4a Mod API: zoom the camera, take the mouse wheel (render, input)
render.set-zoom divides the field of view by up to 10, eased through the
game's FOV easing or at once, with a turning scale (at least 0.05); only
while playing, and the spyglass's own view wins. The strongest of several
mods' zooms counts, and the built-in zoom keeps working beside it.

input.claim-wheel takes the wheel from the hotbar while playing: its steps
are the mod's wheel events. That is the one input a mod consumes; its keys
never do (docs/api/stability.md). A screen opening now lets the mod's held
keys up, and mod keys bound to mouse buttons (key.mouse.4) are heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 b7b7cd6c50 Merge main into feat/mod-input: input beside command and render
Capability::ALL is 9 with Input; the SDK re-exports command and input;
the API reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:09:13 +01:00
Ludovic Marie 74b08e6310 Merge pull request #1928 from levzzz5154/fix/end-city-banner-patterns
🤖 Agent: Thanks levzzz5154! End city banners keep their template patterns; I pushed 401ac1322 so the structure code and the save loader share one banner decoder. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:08:48 +01:00
Ludovic MarieandClaude Sonnet 5.5 1dff593ac3 Merge feat/shader-packs-shadow-entities into feat/shader-packs-shadow-casters
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:05:49 +01:00
Ludovic Marie 68d1a8a388 Merge pull request #1769 from ludovic111/feat/shader-packs-entity-spans
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:04:53 +01:00
Ludovic MarieandClaude Sonnet 5.5 4292806437 Merge feat/shader-packs-shadow-terrain into feat/shader-packs-shadow-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:04:24 +01:00
Ludovic MarieandClaude Sonnet 5.5 c841ab6d56 Merge feat/shader-packs-shadow-buffers into feat/shader-packs-shadow-terrain
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:02:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 8b7315a6ff Merge feat/shader-packs-shadow-programs into feat/shader-packs-shadow-buffers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:01:43 +01:00
Ludovic Marie 76be4d0e73 Merge pull request #1756 from ludovic111/feat/shader-packs-sildurs
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:01:05 +01:00
Ludovic MarieandClaude Sonnet 5.5 b2a7e77036 Merge feat/shader-packs-shadow-camera into feat/shader-packs-shadow-programs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:00:08 +01:00
Ludovic MarieandClaude Sonnet 5.5 86a2f8e470 Merge feat/shader-packs-hand into feat/shader-packs-shadow-camera
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:58:53 +01:00
Ludovic Marie 9d07d5a3e2 Merge pull request #1755 from ludovic111/feat/shader-packs-mip-current
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:58:43 +01:00
Ludovic Marie 298a9778a7 Merge pull request #1754 from ludovic111/feat/shader-packs-biomes
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:58:14 +01:00
Ludovic MarieandClaude Sonnet 5.5 ffda62db09 Merge feat/shader-packs-held-items into feat/shader-packs-hand
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:53:39 +01:00
Ludovic Marie d4526191ce Merge pull request #1744 from ludovic111/feat/shader-packs-normals
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:52:49 +01:00
Ludovic MarieandClaude Sonnet 5.5 ae3bbe6bb4 Merge feat/shader-packs-weather into feat/shader-packs-held-items
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:52:31 +01:00
Ludovic Marie b3fabc1743 Merge pull request #1699 from ludovic111/feat/shader-packs-run-globals
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:51:12 +01:00
Ludovic MarieandClaude Sonnet 5.5 34397fe09d Merge feat/shader-packs-clouds into feat/shader-packs-weather
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:50:35 +01:00
Ludovic Marie c56102125b Merge pull request #1799 from cristibctr/codex/fix-1649-resource-pack-cache
🤖 Agent: Thanks cristibctr! Windows pack cache recovery; it bumps the cache version, so every platform rebuilds that cache once. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:50:17 +01:00
Ludovic MarieandClaude Sonnet 5.5 94c1b5bfaa Merge feat/shader-packs-translucent-entities into feat/shader-packs-clouds
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:49:32 +01:00
Ludovic MarieandClaude Sonnet 5.5 eab457d0ae Merge feat/shader-packs-entities into feat/shader-packs-translucent-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:48:31 +01:00
Ludovic Marie b3bc8e807e Merge pull request #1946 from ludovic111/feat/prepare-output-version
🤖 Agent: verified: the launcher keeps prepared resources while the preparer's OUTPUT_VERSION (or a legacy fingerprint for v0.2.49 to v0.2.64) is the same. Unit tests and clippy pass. End to end with the real v0.2.64 release: an update to a build with OUTPUT_VERSION 1 reused its resources, and a build with the number bumped to 2 did not. Merging.
2026-10-26 02:47:48 +01:00
Ludovic MarieandClaude Sonnet 5.5 02248f4314 Merge feat/shader-packs-alpha-test into feat/shader-packs-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:45:08 +01:00
Ludovic Marie dd1581c062 Merge remote-tracking branch 'origin/main' into cg15/1965
# Conflicts:
#	crates/horizon-prepare/src/lib.rs
2026-10-26 02:44:23 +01:00
Ludovic Marie 5450f5e872 Merge pull request #1698 from ludovic111/feat/shader-packs-terrain-draw
🤖 Agent: shader packs clean room 14 brought up to date with main (merge commit); verify passed on this head; parity skipped by parity-scope.
2026-10-26 02:44:07 +01:00
Ludovic MarieandClaude Sonnet 5.5 b0fd6e660e Merge feat/shader-packs-entity-spans into feat/shader-packs-alpha-test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:41:50 +01:00
Ludovic Marie 5ba4793d35 Merge pull request #1960 from rdbtCVS/t3/optimize-launcher-resource-prepare-1
🤖 Agent: Thanks rdbt! Verified on the Mac mini:
2026-10-26 02:41:36 +01:00
Ludovic Marie f565f67ee5 Merge pull request #1958 from rdbtCVS/t3/item-frame-name-display
🤖 Agent: Thanks rdbt! Matches ItemFrameRenderer: a targeted frame shows its item's custom name. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:39:17 +01:00
Ludovic MarieandClaude Sonnet 5.5 91b0db48e7 Merge feat/shader-packs-sildurs into feat/shader-packs-entity-spans
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:38:42 +01:00
Ludovic MarieandClaude Sonnet 5.5 01ef17a258 Merge feat/shader-packs-mip-current into feat/shader-packs-sildurs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:37:36 +01:00
Ludovic Marie 559ea59fcb Merge pull request #1952 from ludovic111/ci/linux-runner
🤖 Agent: ready. Linux runner support (runner-env/vanilla-data Linux paths, linux-runner.sh, runner_pause.py with Ludovic's lock/idle-15-min rule, heat, game, GPU and pause-file checks), desktop parity host, nightly and parity-main pinned to the Mac. verify and parity passed at this head. The desktop runner stays without the horizon-game label until its trial is identical: that is a runner setting, not in this PR. Desktop full run: 342/343 in 39.3 min (the 1 is a harness timing flake, fixed by #1997).
2026-10-26 02:36:59 +01:00
Ludovic Marie 09b3057610 Merge pull request #1926 from Domanffe/fix/enchantment-names-language
🤖 Agent: Thanks Domanffe! Enchantment names and levels follow the selected language; the server thread shares the client's language, and with none loaded (dedicated server, probes) the English names stay. I pushed a doc tweak (b7629cf65). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:36:25 +01:00
Ludovic MarieandClaude Sonnet 5.5 2203c1777a Merge feat/shader-packs-biomes into feat/shader-packs-mip-current
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:36:22 +01:00
Ludovic MarieandClaude Sonnet 5.5 04000cb4db Merge feat/shader-packs-normals into feat/shader-packs-biomes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:35:21 +01:00
Ludovic MarieandClaude Sonnet 5.5 3f10f57178 Merge feat/shader-packs-block-ids into feat/shader-packs-normals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:34:28 +01:00
Ludovic Marie 894576e1e9 Merge pull request #1914 from socksprox/fix/mod-ui-screen-capture
🤖 Agent: Thanks socksprox! Refactor of the mod screen widgets plus a mouse recapture when a mod's screen goes away. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:34:15 +01:00
Ludovic MarieandClaude Sonnet 5.5 2b05e2809b Merge feat/shader-packs-arena-storage into feat/shader-packs-block-ids
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:32:06 +01:00
Ludovic Marie a83cf01169 Merge pull request #1943 from socksprox/fix/particle-atlas-reload
🤖 Agent: Thanks socksprox! Particles keep their sprites across a resource reload (content root set again on reload). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:31:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 b023ab934a Merge feat/shader-packs-sun-moon into feat/shader-packs-arena-storage
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:30:35 +01:00
Ludovic Marie 834a84f198 Merge pull request #1945 from rdbtCVS/t3/fix-anvil-combining-sound
🤖 Agent: Thanks rdbt! Matches AnvilMenu, which sends the level event with a null player so every player hears it once; lan_e2e's anvil check heard it exactly once on the host and once on the guest. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:29:05 +01:00
Ludovic MarieandClaude Sonnet 5.5 181d188324 Merge feat/shader-packs-sky into feat/shader-packs-sun-moon
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:28:17 +01:00
Ludovic MarieandClaude Opus 5.5 d3af1e04e0 Mannequins run no mob goals and never despawn
A mannequin is an Avatar (a LivingEntity), not a Mob: its serverAiStep has
no goals and LivingEntity has no checkDespawn. With the passive brain a
summoned mannequin wandered off (14 blocks in 400 ticks on the server
thread) and could despawn far from players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:27:37 +01:00
Ludovic Marie 93d58b7024 Merge pull request #1954 from rdbtCVS/rdbt/fix-1949-leash-death
🤖 Agent: Thanks rdbt! Matches Leashable.tickLeash / dropLeash: a leashed mob that dies drops its lead; the new mob_ai scenarios run against the vanilla oracle. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:26:53 +01:00
Ludovic Marie d4e1b3c773 Merge pull request #1922 from rdbtCVS/t3/fix-crop-planting-harvesting
🤖 Agent: Thanks rdbt! Matches ServerPlayerGameMode.handleBlockBreakAction: an instant survival START never leaves a block being destroyed, so no repeated hit sounds and a replanted crop isn't broken again. It now includes #1966's sound regression. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:25:58 +01:00
Ludovic MarieandClaude Sonnet 5.5 f299e18fc6 Merge feat/shader-packs-draw-uniforms into feat/shader-packs-sky
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:25:46 +01:00
Ludovic Marie 1da949efbd Merge pull request #1919 from sl4ppleware/fix/heart-blink-on-set-health
🤖 Agent: Thanks sl4ppleware! Matches LocalPlayer.hurtTo: the hearts blink again when hurt, and the remote client ticks the blink through its controlled simulation tick. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:25:31 +01:00
Ludovic MarieandClaude Sonnet 5.5 0f1ab46921 Merge feat/shader-packs-mipmaps into feat/shader-packs-draw-uniforms
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:24:37 +01:00
Ludovic MarieandClaude Sonnet 5.5 f1fc01ffcf Merge feat/shader-packs-timing into feat/shader-packs-mipmaps
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:23:46 +01:00
Ludovic Marie 80e1eb20cc Merge pull request #1883 from ludovic111/fix/copper-golem-statue-poses
🤖 Agent: merged after verify (bugs-dezerx-1's fix for a player's bug list, self-checked with pr_ready); harnesses run on main by parity-main, which gates releases.
2026-10-26 02:22:56 +01:00
Ludovic MarieandClaude Sonnet 5.5 4ee54d84a8 Merge feat/shader-packs-water into feat/shader-packs-timing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:22:48 +01:00
Ludovic Marie e93aba600b Merge pull request #1941 from rdbtCVS/t3/preserve-gui-scale-setting
🤖 Agent: Thanks rdbt! Matches vanilla: the saved GUI scale isn't clamped while a temporary small surface is up, the HUD caps the scale it draws with. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:20:26 +01:00
Ludovic MarieandClaude Opus 5.5 24b26da5d6 server_ops_feedback: wait for the save's reply before the next step
On the desktop runner (8 harnesses at a time) vanilla's save-all took 2 s,
the whole step: the transcript ended before "Saved the game" and the
harness failed with Horizon right. The save step now waits for that console
line, on both sides, before its usual 2 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:19:17 +01:00
Ludovic Marie 3bea1cd023 Merge pull request #1937 from rdbtCVS/fix/android-helper-scheduling
🤖 Agent: Thanks rdbt! Android-only cfg change to helper thread priorities; the desktop targets compile the same code as before. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:17:46 +01:00
Ludovic MarieandClaude Sonnet 5.5 e5f4d02549 Merge feat/shader-packs-real-buffers into feat/shader-packs-water
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:16:49 +01:00
Ludovic Marie a7d1d17ac9 Merge pull request #1991 from ludovic111/fix/pr-ready-affected-once
🤖 Agent: merged on verify: tooling only, it changes how fast harnesses are chosen, not which game code runs. Fixes pr_ready's 14+ min planning (affected_harnesses was called once per harness) and bounds the selection.
2026-10-26 02:15:09 +01:00
Ludovic Marie 3093f22a8a Merge pull request #1903 from rdbtCVS/fix/clear-offhand-slot-order
🤖 Agent: Thanks rdbt! Checked against vanilla's Inventory order (armour FEET to HEAD, then the off hand) when items are cleared. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:14:29 +01:00
Ludovic MarieandClaude Sonnet 5.5 f1a3409721 Merge feat/shader-packs-custom-uniforms into feat/shader-packs-real-buffers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 7b0192118e parity: choose harnesses faster (shared parse cache, early stop, time budget)
- The reach parse cache lives in the shared parity folder, keyed by content,
  so a new worktree starts warm (written atomically, other worktrees'
  entries kept within bounds).
- A harness's reach search stops as soon as it reaches a changed file that
  differs in more than its `mod` declarations; partial searches are not
  memoized. Declaration diffs are memoized (one git show per file).
- A harness whose changed dependency is outside crates/ is picked without a
  search (narrowing always keeps those).
- After PARITY_REACH_BUDGET seconds (default 180) the remaining harnesses
  count every source their probes link.
- crate_of is cached; no pathlib in the hot paths.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:13:09 +01:00
Ludovic MarieandClaude Sonnet 5.5 ff3d57ea9c Merge feat/shader-packs-run-globals into feat/shader-packs-custom-uniforms
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:12:44 +01:00
Ludovic Marie f10a7cd880 Merge pull request #1994 from ludovic111/fix/vanilla-data-failed-prepare
🤖 Agent: ready. A failed horizon-prepare now leaves nothing in .local, and stale real .local/horizon-client* folders and preparations lacking horizon-client are cleaned at job start. Tested on a fake checkout (fail, stale, cached). verify passed; parity skipped (scope). No overlap with #1965.
2026-10-26 02:12:07 +01:00
Ludovic MarieandClaude Sonnet 5.5 b73ca494a8 Merge feat/shader-packs-terrain-draw into feat/shader-packs-run-globals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:11:28 +01:00
Ludovic Marie b10d927e5e Merge pull request #1929 from ludovic111/feat/mod-render
🤖 Agent: Mod API capability render (experimental): HUD shapes and camera shake. Main merged in beside command (8 capabilities). Verified: horizon-mods tests, clippy with and without mods, earlier pr_ready, harnesses and in-game runs.
2026-10-26 02:10:10 +01:00
Ludovic MarieandClaude Opus 5.5 a9b5ca3936 Merge main: test beside main's new templates
The usage line, the docs and the website list `test` with main's
`new --template code|content`; the code template keeps tests/hello.toml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:08:41 +01:00
Ludovic MarieandClaude Opus 5.5 07f1bcff68 Merge main: render beside main's command capability
Both add a Capability variant: the list holds 8 now. The SDK re-exports
both interfaces; the API reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:02:00 +01:00
Ludovic MarieandClaude Sonnet 5.5 2a85b6a432 Merge origin/main into fix/remote-tnt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:53 +01:00
Ludovic MarieandClaude Sonnet 5.5 48121cb109 Merge origin/main into chore/wasmtime-48
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:44 +01:00
Ludovic MarieandClaude Sonnet 5.5 b222dd9194 Merge origin/main into feat/server-dialogs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:19 +01:00
Ludovic MarieandClaude Sonnet 5.5 35e047fb8e Merge origin/main into feat/yung-port-as-written
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:00 +01:00
Ludovic MarieandClaude Opus 5.5 d782e455a4 docs: the desktop's CI slice gets half the threads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:55:57 +01:00
Ludovic Marie ee403545ce Merge pull request #1805 from kacpherek/pr/pregen
🤖 Agent: thank you kacpherek! /pregen is in, off by default: a server turns it on with horizon-pregen=true in server.properties (server.toml [properties]), single player and LAN with --pregen. With it off, the command tree and /help match vanilla. Tested on a dedicated server through horizon-server (on: 1,369 chunks generated and saved), single player on the server thread and in-frame, and 11 harnesses including command_exec.
2026-10-26 01:54:20 +01:00
Ludovic MarieandClaude Opus 5.5 160c1e8b7a CI: a failed preparation leaves nothing in .local
When horizon-prepare failed, vanilla-data.sh deleted the preparation folder but kept what it had
already written to .local/ (horizon-client, horizon-client-motion, ...). The next job then saw those
real folders as already there: its preparation listed only horizon-client-structures in .entries,
and the checkout kept using the stale resources outside the per-key cache.

Now a failed preparation removes the .local/ entries it created; a job first removes real folders
named like preparation outputs, and makes again a preparation whose .entries lacks horizon-client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:54:02 +01:00
Ludovic Marie 98f5a811cc Merge pull request #1829 from ludovic111/fix/global-level-events
🤖 Agent: merged after verify (bugs-dezerx-1's fix for a player's bug list, self-checked with pr_ready); harnesses run on main by parity-main, which gates releases.
2026-10-26 01:50:29 +01:00
Ludovic MarieandClaude Sonnet 5.5 7bbd715c8c Merge origin/main into feat/shader-packs-terrain-draw
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 22980fc8cc Extras: the FPS counter and keystrokes move to the mod browser
They are official mods now (#1982). The Extras page loses their rows and
the HUD its built-in drawing. A player who had one on gets the mod on the
first start: installed from Horizon's registry, checked as the launcher
checks it (size, SHA-256, signature, manifest), turned on, with the
corner and options carried into the mod's storage. One chat line says so
("FPS counter moved to the mod browser: installed for you"). If it can't
be installed (offline), a line says Horizon tries again at the next
start. horizon-extras.txt keeps the old keys and lists what was done in
movedToMods.

The client's horizon-mods dependency gains the `index` feature for the
check. The frame and click counters now run only for mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:41:18 +01:00
Ludovic MarieandClaude Opus 5.5 49bbdf8954 CI: the desktop's CI slice gets half the threads
On all 24, a cargo build held ludodesktop's CPU at 92 °C and the watcher kept pausing; at half,
harnesses run at a median of 80 °C.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:38:23 +01:00
Ludovic MarieandClaude Opus 5.5 bdbf67ef93 pr_ready: work out the affected harnesses once, not once per harness
affected_here called parity.affected_harnesses inside the filter, once for
each of the 343 harnesses. Since the selection reads the Rust items each
probe reaches (#1964) one call takes about a minute and a half, so
pr_ready sat for hours before its first step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:31:38 +01:00
Ludovic MarieandClaude Opus 5.5 9db6a0ee48 CI: parity-main stays on the Mac; heat check on a median, boot as the first sign; nix roots fixed
nix-build names a dev output's GC root <name>-dev, so the runner's PKG_CONFIG_PATH pointed at links
that didn't exist and every client build failed on libudev; it now uses the store path nix-build
prints. The package temperature spikes for a second under load, so the heat check takes the median
of 5 readings. Restarting the watcher no longer throttles a running job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:23:27 +01:00
Ludovic MarieandClaude Opus 5.5 4c78d1b4f8 Merge origin/main into ci/linux-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:23:12 +01:00
Ludovic MarieandClaude Opus 5.5 045bda8bfc release: give the exporters job an hour, as the Mac builds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 8b26c4cf34 Ring search thread: start once generation pauses
In the idle class the thread still slowed a fresh world's first 600 chunks
by about 3 s on a 4-thread laptop (it shares a physical core with a worker).
It now waits until no proto chunk was generated for 2 s, or a minute after
the world opened, and gives up if the world closes first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:14:39 +01:00
Ludovic MarieandClaude Opus 5.5 1dd47581de Ring search thread: the idle class, not behind the server
On a 4-thread laptop the background search at nice +10 took a share of the
cores from the chunk workers: 600 chunks arrived 3-4 s later. The thread now
runs only on cores nothing else wants (Linux SCHED_IDLE, macOS background
QoS, Windows idle priority): horizon_threads::idle_current.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:59:15 +01:00
Ludovic MarieandClaude Opus 5.5 40f79354d6 release: build the preparer and compile the exporters fresh in the exporters job
The clean checkout removes target/, and vanilla-data.sh builds horizon-prepare only when it
prepares afresh, so compile-exporters had no binary on a cached preparation. The bundle is
also compiled in the job instead of reused from .local/exporters, which vanilla-data.sh links
to preparations that pull requests' parity jobs write, and the job fails if anything but our
classes and the manifest would leave the runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:40:43 +01:00
Ludovic MarieandClaude Opus 5.5 0a8af38ac4 Rings: search them on a thread behind the server, as the world opens
The previous commit decides chunks far from the rings without them. The
128 biome searches now start on a helper thread as an overworld with
structures opens, behind the server and render threads, so an eye of ender
or a chunk near a ring finds them done or shares what is left, instead of
running them all on the thread that asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:39:18 +01:00
rdbtCVSandCodex 0b970c0c9a Cover instant-break sounds alongside replanting
Move the instant_digging regression from redundant PR #1966 into the existing stale-dig fix for #1880 and #1908.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 23:35:22 +00:00
Ludovic Marie da66056bd5 Merge remote-tracking branch 'origin/main' into perf/behaviour-cache-hash-once 2026-10-26 00:33:29 +01:00
Ludovic Marie d5699d5f90 Merge remote-tracking branch 'origin/main' into perf/sculk-ticking-chunks 2026-10-26 00:33:28 +01:00
Ludovic Marie 7092d5f4d7 Merge remote-tracking branch 'origin/main' into perf/rings-out-of-reach 2026-10-26 00:33:28 +01:00
Ludovic MarieandClaude Opus 5.5 e5403c0224 Merge origin/main into fix/item-cooldowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:33:20 +01:00
Ludovic Marie 1fd42f311f Merge remote-tracking branch 'origin/main' into perf/collision-scan-edges 2026-10-26 00:31:46 +01:00
Ludovic MarieandClaude Opus 5.5 0baf6cbd62 CI: the desktop runner works while its screen is locked or untouched for 15 min
Ludovic's rule (2026-10-26): runner_pause.py pauses at once on keyboard, mouse or gamepad input
(read from /dev/input, only whether bytes came), on an unlocked session touched in the last
15 min, at 90 °C, on a game or the GPU. Fixes the service start: the link loop's last test
failed the script under set -e.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:27:22 +01:00
Ludovic MarieandClaude Opus 5.5 ef5361e4c2 Creatures: drop the doc comments the removed cooldown fields left behind
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:26:17 +01:00
socksproxandDevin ea1c0a4490 Render mannequin entities (server NPCs)
Servers on 26.3 spawn NPCs as minecraft:mannequin — the avatar entity
vanilla added for exactly this — instead of fake players (gommehd.net's
"Gommunity" lobby NPCs are invisible today). The client never registered
the type: MobTypes.load only builds species::ALL, so AddEntity produced a
tracked entity but nothing to draw.

- EntityDataSerializers.RESOLVABLE_PROFILE (41) decodes into
  Value::Profile. Before, the entry list ended at it and the mannequin's
  DATA_IMMOVABLE and DATA_DESCRIPTION after it never decoded.
- species::ALL gains mannequin (player#main model, Humanoid anim), and
  the player-only render paths — skin texture, sleep pose,
  AvatarRenderer's 0.9375 scale — take an avatar() check instead of the
  id string.
- SetEntityData's apply stores DATA_PROFILE on the mob, and
  update_remote_skins resolves its textures property through
  PlayerSkinRenderCache's rules (unsigned allowed, SkinManager's
  insecure path) the way a tab player's skin resolves.
- Mannequins get the shared-flags crouch and sleeping-position handling
  other players already had.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 00:19:44 +01:00
rdbtCVSandCodex 3adff150cf Record preparation validation timings and measurement limits
Co-Authored-By: Codex <[email protected]>
2026-10-25 23:15:40 +00:00
Ludovic MarieandClaude Opus 5.5 1e6f05ed5a Chat option: filter the chat's lines both ways, as setVisibleMessageFilter
The chat keeps where each message came from and the Chat option's
abilities. A message the option hides is still dropped on arrival, but a
change of the option now also hides the lines already in the chat that
it no longer shows, and brings them back when it shows them again
(ChatComponent.setVisibleMessageFilter -> refreshTrimmedMessages), on
apply_settings. Every Chat::add names its source. The Restrictions
screen reads its two flags from ChatAbilities instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:15:37 +01:00
rdbtCVSandCodex 03190ec724 Update the runtime download fixture for runtime-only discovery
Co-Authored-By: Codex <[email protected]>
2026-10-25 23:13:02 +00:00
Ludovic MarieandClaude Opus 5.5 5e9d45bc9a /pregen: off unless the server turns it on
Vanilla has no /pregen, so with it always registered /help and the command
tree differed from vanilla's (the command_exec harness failed on /help).
Horizon's own commands are now added to the game's tree only when the owner
asks: `horizon-pregen=true` in server.properties (`horizon-pregen = true`
under [properties] in server.toml) for a dedicated server, `--pregen` for
the game (single player, LAN). Off, the trees are vanilla's own, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:11:39 +01:00
rdbtCVSandCodex f45e3f1237 Optimize launcher preparation with precompiled, batched exporters
Compile and validate original exporter bundles during release builds, cache development compilation, and reuse initialized JVMs and collision data during preparation. Keep legacy preparers compatible while current releases need only a Java runtime.

Co-Authored-By: Codex <[email protected]>
2026-10-25 23:11:23 +00:00
Ludovic MarieandClaude Opus 5.5 fbd6bee3e3 Official mods: FPS Counter and Keystrokes, the first extras as mods
The FPS counter and keystrokes extras rewritten as mods on the public API
(render, input, ui, storage), in mods/official/, with the extras' options
on their settings pages and the extras' look: the same boxes, colours and
text positions, pixel for pixel. They are workspace members, so clippy and
pr_ready cover them; a maintainer publishes them in horizon-mods. The
built-in extras stay until the removal PR, which installs these for the
players who had them on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 6824224a33 Player heads: read the profile's skin once, and no skull on a piston head
- Skull keeps what its profile draws (the skin URL, its skins/<sha1>
  sprite and the default skin), computed when the block entity loads,
  instead of decoding base64 JSON and hashing it for every head every
  frame; skull_skin_at no longer scans the chunk's saved block entities.
- BlockEntity::for_block gave minecraft:piston_head a skull block entity
  (it ends in _head), which would have been saved as minecraft:skull.
- AGENTS.md is left as it is on main: the workflow note is not part of
  this fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:06:22 +01:00
Ludovic MarieandClaude Opus 5.5 c28bea8e96 Mod API: input's permission line says it sees keys and clicks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:06:16 +01:00
Ludovic MarieandClaude Opus 5.5 2a576b5886 Merge origin/main into fix/water-cauldron-extinguish
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:01:15 +01:00
Ludovic MarieandClaude Opus 5.5 eb443cdacc Merge origin/main into fix/server-player-sculk-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:59:04 +01:00
rdbtCVSandCodex 4be53ee28a Reduce resource preparation filesystem work
Share the asset index and verification state, stage checked imports while hashing, preserve indexed replacements, and clone independent resource copies where supported.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-25 22:58:00 +00:00
Ludovic MarieandClaude Opus 5.5 9d5301627b Chickens: the flap test uses the shared test mob type
chicken_flap reads only the ground flag, so the test builds its mob from
mobs::sensor_scan_tests::kind() instead of a 50-line copy of a MobType.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:55:43 +01:00
Ludovic MarieandClaude Opus 5.5 d5b88ff130 Merge origin/main into fix/chicken-flap-remote
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:55:43 +01:00
Ludovic MarieandClaude Opus 5.5 b7629cf656 Enchantments: say which callers have no language
full_name reads the process's language, which the server thread shares
with its window; only a process with no language loaded (a dedicated
server, the probes) keeps the English names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:46:36 +01:00
Ludovic MarieandClaude Opus 5.5 a413812363 Scroll bars: the screen's GUI size, and a test of the drag
stats_scroll and stats_drag take the GUI height from gui_size() instead
of a copy of it. The Game Rules list's press, drag and release get the
test from #1913 (vlesmentie's take on the same fix).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:46:15 +01:00
Ludovic MarieandClaude Opus 5.5 dd73faccbc Merge origin/main into fix/draggable-scrollbars
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:43:27 +01:00
rdbtCVSandCodex 072f5ce2ee Fix custom item names above targeted item frames
Show the framed item's custom name through the existing world name-label renderer, matching vanilla's HUD and crosshair conditions. Extend the LAN frame check for normal and glow frames.

Co-Authored-By: Codex <[email protected]>
2026-10-25 22:43:07 +00:00
Ludovic MarieandClaude Opus 5.5 401ac1322f Banners: one loader for saved and generated banners
The generated banner reuses save.rs's BannerBlockEntity.loadAdditional
(now decode_banner) instead of a copy of it, and the generated block
entity clones its NBT only for the kinds that read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:43:03 +01:00
rdbtCVSandOpenAI Codex 28206976d8 Move parity fixtures onto server execution
Co-Authored-By: OpenAI Codex (GPT-6.1) <[email protected]>
2026-10-25 22:36:21 +00:00
Ludovic MarieandClaude Opus 5.5 d81aa97e84 CI: the desktop runner works only while nobody is at the screen; desktop as a parity host
runner_pause.py pauses on any seat0 session (locked too), games, GPU use or a pause file, and is
the only thing that starts the runner; a job under way is throttled, then the runner stops. The
desktop host in parity_hosts.json refuses work while paused and its workers stop on a pause. The
nightly audit stays on the Mac mini. Docs and a unit test of the watcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:12:09 +01:00
Ludovic MarieandClaude Opus 5.5 8463325648 CI: Linux runners for the parity job (desktop, ThinkPad), paused while a game runs
runner-env.sh and vanilla-data.sh find Python 3.13, the pinned JDK and sccache on NixOS;
linux-runner.sh installs a runner as a systemd user service with its own screen; runner-pause.py
stops it from taking jobs while a game runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:12:09 +01:00
Ludovic MarieandClaude Opus 5.5 e3bd626688 Mod API: what the extras need as mods: corner panels, frame stats, mappings, buttons
The built-in extras become official mods; these are the calls the FPS
counter and keystrokes overlay need to look and behave the same:

- render.set-corner(corner, w, h, shapes): a box the game places with
  its own corner stacking (under the status effects, with the overlays
  and hud panels), clear-corner; text-width(text) in the HUD font
  (advances for U+0000..U+024F, 6 otherwise); frame-stats(): the game's
  FPS counter (fps, min, 1% low).
- input.mapping(name): a movement or click mapping's key label and
  whether it is held; clicks-per-second() for attack and use.
- ui: a button widget (200 x 20, select(id, 0)) for settings pages.
- The game keeps its FPS and click counters running while a mod runs,
  and refreshes the snapshot every frame for mods with a frame hook.
- The ui test guest gains /corner and /readings, with a sandbox test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:08:15 +01:00
rdbtCVSandCodex 0b49da2466 Fix lead drops when leashed mobs die
Follow Leashable.tickLeash for dying mobs and cover trader/player-held llama leads through the death animation.

Co-Authored-By: Codex <[email protected]>
2026-10-25 22:07:58 +00:00
Ludovic Marie 1a857b285d Merge branch 'feat/prepare-output-check' into fix/prepare-rewritten-version-file 2026-10-25 22:50:36 +01:00
Ludovic MarieandClaude Opus 5.5 acb68ed163 launcher: reuse prepared resources while the preparer's OUTPUT_VERSION is the same
A version's resources were reused only when the preparer's sources and the exporters were
byte for byte the same, so any change to crates/horizon-prepare (even one that prepares the
same files) made every player prepare again. The launcher now compares the number the preparer
declares for what it makes (`pub const OUTPUT_VERSION: u32` in crates/horizon-prepare/src/lib.rs,
bumped by hand when the output changes and checked by CI in the follow-up). Preparers from
before the number are known by a fingerprint of their sources and exporters: v0.2.49 to
v0.2.64 prepare version 1's output (measured on the CI runners' preparations). Older versions
are compared as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:44:56 +01:00
rdbtCVSandOpenAI Codex b6efebc879 Play anvil result sounds once for every player
Deliver anvil level events to the acting player and keep their local sound out of positional sound capture. Extend the anvil LAN check to count sounds for both players using either player’s anvil result.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 21:39:14 +00:00
socksproxandDevin 2cdb6362ca Particles keep their sprites over a resource reload
The particle engine copied the GUI atlas's sprite rects once at start; a pack or server-pack
change rebuilt the atlas but not the engine, so every named and set sprite then drew a stale
region — break crumbs showed other blocks and campfire smoke showed random items (#1923).
The engine now re-reads the rects and the packs' sprite sets on reload, and a particle whose
sprite-set index outlived the rebuild keeps its sprite instead of indexing past the new list.
The reload also layers content mods over the root again, which startup does but reload forgot,
so the first reload silently dropped their assets and data.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 22:38:40 +01:00
vlesmentieandClaude Opus 5.5 15877931db Merge origin/main into fix/water-cauldron-extinguish
Keeps both sides of #1823 (wither rose) and this branch (lava and water
cauldrons) in motion.rs's inside-block match, the MotionEvent variants,
game.rs and lan_actions.rs, and all three tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:22:46 -03:00
levzzz 61c5f29ab7 Game Rules and Statistics: the scroll bars can be dragged
Both lists drew a scroll bar but only listened to the mouse wheel: a press
inside the bar column, the drag that follows and the release were dropped.
`ui::Scroll` already models AbstractSelectionList's press/drag/release (the
server list uses it); the two screens now keep their scrolling in it and the
window passes the press, the pointer's moves while it is held and the release
along.

Fixes #1782
2026-10-26 02:13:10 +05:00
Ludovic MarieandClaude Opus 5.5 fada660310 Mod API: capability input (experimental): claimed items' use, mod keys
- WIT `input`: claim-items(ids) makes the use button on those items the
  mod's: use-start, then use-stop with the ticks held (button up, item
  out of the hand, or a screen opening); the game does nothing else
  with the click. register-key(name, label, key) for up to 16 keys
  (key.keyboard.r), heard as key-down / key-up while playing; the
  game's own mapping on the key still runs. held(), poll() (256 kept).
- game/mod_input.rs: play_mouse asks mod_use first (local and remote
  windows), the tick ends a use whose item left the hand, the keyboard
  and horizon-ctl's key send the physical key to mod_key.
- No claim and no key, no cost: one check each.
- Experimental: the manifest needs experimental = ["input"].
- The ui test guest gains /input_claim, /input_poll and /input_held,
  with a sandbox test. Docs: mods.md, stability.md, the changelog, the
  regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:10:44 +01:00
rdbtCVSandCodex gpt-6.1-sol 40b3c1e3d6 Preserve requested GUI scale across temporary small surfaces
Keep framebuffer limits in rendering and explicit option cycling rather than overwriting the saved choice during startup or settings application.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-10-25 21:08:55 +00:00
Ludovic MarieandClaude Opus 5.5 2fb0ac186a Behaviour cache: hash the source files once per process
The snapshot's key hashes the 70 MB of source tables it was resolved from.
The client's tables and the world generator's are loaded from the same
folder, so a world opening hashed them a second time: a third of a second
of the server thread's start on a 4-thread laptop, before any chunk. The
hash is now kept for the process, by each file's length and modification
time; a changed file is read again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:04:13 +01:00
Ludovic MarieandClaude Opus 5.5 4a2b262343 Structure starts: decide chunks no ring search can reach without the rings
The 128 stronghold biome searches (two seconds of CPU on a 4-thread laptop)
ran when the first chunk of a new world looked up its structure starts, so
nothing near spawn was generated until they were done. Each search ends
within 8 chunks of its ring chunk, which is known before searching, and
strongholds lie 1,400 blocks out or more: a chunk with no ring search in
reach (exclusion zones included) is now decided without them, which is what
they would say. They run when a chunk near a ring, /locate or an eye of
ender needs them (ring_positions now searches on demand instead of returning
nothing before a lookup did).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:00:07 +01:00
socksproxandDevin 820cc211d3 Player heads render their owner's skin
Placed player_head blocks kept no profile: skulls had no block entity, so the
renderer fell back to a hardcoded steve for every head. Give skulls a real
BlockEntity carrying vanilla's SkullBlockEntity fields (profile,
note_block_sound, custom_name) so the placement pipeline lands
minecraft:profile in it the way block_entity_item already maps, encode and
decode it through save so regions keep it, send its update tag to remote
clients, and resolve the profile through the skin downloader the head items
and the tab list share: the downloaded skin slot once it lands, the
profile's DefaultPlayerSkin uuid pick while it fetches, SKIN_BY_TYPE steve
without a profile.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:55:02 +01:00
socksproxandDevin 8dcd00fc62 AGENTS.md: the feature-request loop
When an agent is asked for a feature it runs the whole loop itself: check open
PRs for the area first, work in a new worktree on the latest upstream main,
build and run pr_ready, then push to the fork and open the pull request
against upstream, never the fork.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:54:47 +01:00
rdbtCVSandCodex 95c336a387 fix(threads): lower helper thread priority on Android
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 20:50:57 +00:00
levzzzand[CC] <deepseek-v4.1-flash> b71623dfd7 Item cooldowns: the sweep on a slot, and no use while one runs
Nothing tracked `ItemCooldowns`: `draw_item_decorations` passed a cooldown of
0.0 with a note that there was none to shade, and ender pearls were held back
by an ad-hoc `pearl_cooldown` counter.

`horizon_client::cooldowns` keeps the `use_cooldown` component's entries by
cooldown group the way `Player.getCooldowns` does (start and end ticks, the
percent `getCooldownPercent` returns), `ItemInfo` now carries the item's
`use_cooldown`, and the game uses it where vanilla does:
`ServerPlayerGameMode.useItem`/useItemOn refuse a stack on cooldown,
`ItemStack.applyAfterUseComponentSideEffects` starts the entry when a use
finishes (a thrown pearl, a wind charge, an eaten chorus fruit),
`GuiGraphicsExtractor.itemCooldown` shades the slot, and a server's guests
keep their own and are sent `ClientboundCooldownPacket` (started, and 0 when
it ends), which a client applies.

Fixes #1763

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 01:50:22 +05:00
Alexander KrylovandHermes Agent 3762ab7cda Enchantments: the name and the level follow the selected language
The registry resolved every `description` from the pinned `en_us` map at load
and `full_name` took `enchantment.level.<n>` from the same table, so a tooltip
showed an English enchantment line whatever language the player picked.

Keep the description's translation key and resolve it, and the level, through
the current language at display time, with the English text as the fallback for
the server thread, the probes and an old resource root.

Refs #1774.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 23:47:14 +03:00
levzzzand[CC] <deepseek-v4.1-flash> b3807d6e50 Chat: the Chat option hides what it says it hides
The Chat Settings option was saved and shown but never applied: Hidden and
Commands Only still sent and displayed ordinary chat, and Hidden still
displayed the server's messages.

Minecraft.computeChatAbilities turns the option into ChatRestriction's
permissions, and they are now honoured where vanilla honours them:
ChatScreen refuses to submit input they take (hasAllowedInput) and says why
in a red usage line, ClientPacketListener.sendChat/sendCommand stop it before
the packet, ChatListener drops the player, server and client sources the
option hides, and ClientInformation carries the visibility to a server (at
login, and again when it changes, as Options.broadcastOptions does).

Fixes #1376

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 01:45:17 +05:00
rdbtCVSandCodex 09ff66bfee Fix stale server digging after instant survival breaks
Clear completed guest digging so replanted flowers and crops are not broken again after attack is released. Add a LAN replanting regression for both single-player paths.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 20:39:44 +00:00
Ludovic MarieandClaude Opus 5.5 61c44c22ef Mod API: capability render (experimental): HUD shapes and camera shake
Mods draw through the game's own renderer, never their own shaders:

- WIT `render`: set-hud(shapes) with fill, quad (any four corners),
  sprite (the game's GUI sprites), item (scaled) and text shapes, up
  to 1024, kept until replaced; gui-size(); shake(strength, seconds).
- Drawn over the HUD after the extras, under chat and screens, hidden
  with F1 (game/mod_render.rs). Colours are 0xAARRGGBB.
- shake turns the view, not the player: jolts fade out squared, add up
  to 1, at most 3 degrees of yaw and 2 of pitch, scaled by Distortion
  Effects (screenEffectScale; 0 turns it off).
- Experimental: the manifest needs experimental = ["render"].
- No mod drawing, no cost: an empty iterator and an is_empty check.
- cargo horizon-mod check reports shakes and HUD shapes. The ui test
  guest gains /render_hud, /render_clear and /render_shake, with a
  sandbox test; HudBatch gets fill_quad.
- Docs: mods.md, stability.md, the changelog, the regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:35:12 +01:00
sl4ppleware 386912a76d Hearts blink again when the server sets health: LocalPlayer.hurtTo's damageCooldownTime 2026-10-25 23:32:27 +03:00
vlesmentieandClaude Opus 5.5 72de6e0b70 Water cauldrons put out a burning player and lose a level
On top of the lava cauldron's inside test: water and powder snow
cauldrons are no longer skipped. Their entity-inside shape is the
cauldron's or their content, Block.column(12, 4, 6 + 3 per level), and
LayeredCauldronBlock.entityInside applies: on a server, a burning
player first asks for handleEntityOnFireInside (runBefore EXTINGUISH),
which lowers the fill level (lowerFillLevel, a powder snow cauldron
turning to water of its level first, block_change with no source); then
EXTINGUISH (Entity.clearFire) puts the fire out.

Fixes #1330.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:48 -03:00
Ludovic MarieandClaude Opus 5.5 ac47b988c9 Shader packs, clean room 42: culled entities, the first-person player, shadowPlayer and shadowLightBlockEntities in the shadow pass
Entities Horizon's entity culling hides from the camera keep their vertices in a hidden span that
only the pack's shadow pass draws; the local player's model is built in first person, hidden from
the camera, while the pack casts entity shadows. shadowPlayer draws the player alone when
shadowEntities is off, shadowLightBlockEntities the light-emitting block entities when
shadowBlockEntities is off. Without a shader pack nothing changes.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, §2.4, §3.2; no other
loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:21:05 +01:00
Ludovic MarieandClaude Opus 5.5 a013e35a61 Collision scans: pass the rest of an outer row in a contained section at once
On the rows along a box's bottom, top and sides every cell is outer, so in a
section no block reaches out of, the rest of the row up to the chunk's edge
is passed in one step instead of cell by cell. The candidate budget still
ends the scan where it did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:17:59 +01:00
socksproxandDevin 74e3b0adf6 ui: recapture the mouse when a mod's screen vanishes, borrow its widgets
Review on #1864: when a mod stops while its screen or settings page is
up, draw_mod_screen dropped the player back to Playing without
capture(true), leaving the mouse free until the next click into the
world. The dead surface now leaves through close_mod_screen, which puts
back the screen it covered and holds the mouse on a return to Playing;
the same goes for a screen dropped while it was up.

mod_ui_view also cloned the title and every widget (up to 64, lists to
512 rows) on each frame, click and scroll. It now borrows the mod's
stored widgets — the screen's scrolls live in a disjoint field — and the
click finds the picked row first so the &mut ui_select runs after.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:10:52 +01:00
vlesmentieandClaude Opus 5.5 de0f76476f Big dripleaves tilt under a player standing on them
The leaf's tilt ticks, its reset and its redstone reset were already in
horizon-world (big_dripleaf_tick, big_dripleaf_reset_tilt), but nothing
started them: BigDripleafBlock.entityInside was missing. An entity on
the ground whose feet are above the leaf's y + 0.6875 (canEntityTilt)
tilts a flat, unpowered leaf UNSTABLE and schedules its tick in 10.

The block tick's inside scan now also passes the host's and the LAN
guests' feet and on-ground flag to Behaviours::entity_on_big_dripleaf.

Fixes #1683.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:10:26 -03:00
Ludovic MarieandClaude Opus 5.5 200b756b4a Sculk: pass over idle sensors and catalysts in the block entity tick
A sculk sensor, shrieker or catalyst with no vibration chosen, travelling or
waiting, and no charge cursors, ticks without changing anything (vanilla's
VibrationSystem.Ticker.tick and SculkSpreader.updateCursors return at once).
The tick now leaves those out before asking whether the chunks around them
tick (nine lookups each) and before looking them up a second time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:53:31 +01:00
levzzzand[CC] <deepseek-v4.1-flash> bb9c474c64 End city banners keep their template patterns
A structure template saves a banner's layers in its block entity NBT, and
StructureTemplate.placeInWorld hands that NBT to the level, where
BannerBlockEntity.loadAdditional reads `patterns`. Horizon built the client
block entity from the block alone (BlockEntity::for_block), so the End
cities' magenta banners came out bare.

Fixes #1438.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 00:53:17 +05:00
Ludovic MarieandClaude Opus 5.5 4459f132fe Collision scans: skip the outer cells of sections no block reaches out of
BlockCollisions keeps a block on the cells around the box (Cursor3D types 1
and 2) only if it has a large collision shape or is a moving piston. Most
sections hold neither in their palette, so the scan now asks the provider
once per section and passes those cells without reading them: a mob's box
reads about a fifth of the cells it did. Same hits, same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:44:09 +01:00
vlesmentieandClaude Opus 5.5 89d6e1c2a5 Structure chests keep the enchantments their loot rolled
The loot rolls carry enchantments and potions (horizon_world::loot runs
enchant_with_levels, enchant_randomly and set_potion), but chests filled
when a structure is placed, and mineshaft chest minecarts, turned each
LootStack into an item with loot_item, which keeps only the item, count,
damage and exploration map. End city gear came out bare. Both now use
loot_item_with_components, as vaults, trial spawners and decorated pots
already do.

Fixes #1437.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:34:39 -03:00
rdbtCVSandOpenAI Codex GPT-6.1 84a3f1ee18 Emit sculk movement events for each server player
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-25 19:30:34 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 9c7909f770 Advance remote fireballs between server tracker updates
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-25 19:23:55 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 481e75bc33 Clear off-hand items in vanilla inventory slot order
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-25 19:16:36 +00:00
Ludovic MarieandClaude Opus 5.5 dab13669f5 Creative screen drops reach the server: SetCreativeModeSlot(-1)
A stack picked from a creative tab exists only on the client. Thrown out of
the creative screen as a server's client, it went out as a ContainerClick to
slot -999, which the server had no carried stack for, so nothing dropped. The
client now sends handleCreativeModeItemDrop's SetCreativeModeSlot(-1, stack),
and the server drops it from the player's hand, with vanilla's drop spam
throttle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:53:09 +01:00
vlesmentieandClaude Opus 5.5 e4a151c2e9 Menu buttons show the pointing hand under the mouse
26.3's AbstractWidget.handleCursor asks for CursorTypes.POINTING_HAND
when a widget is hovered, or NOT_ALLOWED when it is inactive, and the
frame applies the last request to the window (GuiGraphicsExtractor
requestCursor and applyCursor, Window.selectCursor). Horizon never
changed the cursor, so it stayed an arrow over buttons.

widgets.rs keeps the frame's request, ui::button and the resource pack
screen's buttons make it from the mouse hover (not keyboard focus), and
the game sets winit's Pointer, NotAllowed or Default cursor after each
frame when it changes.

Fixes #1347.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:49:15 -03:00
Ludovic MarieandClaude Opus 5.5 96199c23e7 Shader packs, clean room 41: entities and block entities in the shadow pass
shadow_entities and shadow_block (each falling back to shadow) load with
the entities' vertex inputs unless shadowEntities or shadowBlockEntities is
false (spec 2.4, 3.2; both on by default, OQ-19). They draw the same entity
and block-entity runs the gbuffers programs would, with the same per-draw
uniforms, into the shadow map after the solid terrain and before the
shadowtex1 copy. A run is uploaded when a gbuffers program or a shadow
program draws it; Horizon still draws the runs no gbuffers program covers.

Without a pack nothing changes: only the pack's entity code is touched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:20:17 +01:00
vlesmentieandClaude Opus 5.5 2c8930c48d Chickens flap while they fall on a client of a server
Chicken.aiStep updates flap and flapSpeed on the client too, for the
chickens it only displays. tick_remote_entities never did, so with
single player on its server thread (and on servers) a falling chicken
kept its wings folded. The wing part of chicken_ai_step is now
chicken_flap, and the remote tick runs it from the server's on-ground
flag; the falling slowdown and egg laying stay the server's.

Fixes #1706.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:16:32 -03:00
Ludovic MarieandClaude Opus 5.5 aa2f60b7bd Copper golem statues turn to their next pose when right-clicked
CopperGolemStatueBlock.useItemOn: anything but an axe, an empty hand too,
cycles copper_golem_pose with its sound and block_change game event; honeycomb
on a weathering statue passes on to waxing. Axe scraping, unwaxing and
stripping and honeycomb waxing now count the item as used, and waxing sends
its block_change game event, as vanilla's useOn does. The use_item_on harness
gains statue cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:43:03 +01:00
Ludovic MarieandClaude Opus 5.5 c965aad8d3 Shader packs, clean room 40: terrain in the shadow map, culled from the light
The shadow program now draws the world's terrain from the light's view.
The shadow pass culls its own section list against the shadow camera's
frustum (render distance, capped by shadowDistanceRenderMul), nearest
the light first, and draws only the faces toward the light. Its draws go
by arena page within 64-block bands from the light, as the camera's solid
draws do, so the flat path draws them in about ten calls per list instead
of one per page change (over a thousand with BSL). Its draws
follow the camera's translucent draws in the same instance and indirect
buffers, so the flat path gives them calls of their own. Sections never
sorted for the camera are sorted once so their translucent indices are
valid.

The frame's first shadow pass clears the shadow map and shadowcolor
buffers as it begins, instead of a separate pass that loads and stores
them all; the translucent shadow pass is timed under its own label.

Without a pack nothing changes: the shadow lists are empty and the
terrain stays one draw call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:36:20 +01:00
Ludovic MarieandClaude Opus 5.5 a276f81aba Shader packs, clean room 39: the run doc comment back on run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:55:23 +01:00
Ludovic MarieandClaude Opus 5.5 1a39af6b62 /pregen: plan any radius at once, and skip a strip's rows outside the circle
Pregen::new counted the area chunk by chunk and split the strips with
an i32 product: /pregen start 30000000 overflowed, then hung the server
thread for hours. Rows are now counted from the circle's half-width,
the strip edges in i64, and each strip starts and ends at the rows the
circle reaches, so a band never walks empty corner rows. Unit tests
check that every chunk of squares and circles is swept once and that
the largest radius plans at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:36:22 +01:00
Ludovic MarieandClaude Opus 5.5 71f0ffde4b Prepare: a launcher's rewritten version file falls back to Mojang's (#1772)
Launchers like HMCL rewrite versions/<v>/<v>.json in the official layout
(same jars and libraries, their own fields), and horizon-prepare refused it
as a version Mojang may have republished. Mojang's copy saved as the imported
manifest.json is now used instead, and without one the error says the
launcher may have rewritten it and how to get Mojang's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:31:44 +01:00
Ludovic MarieandClaude Opus 5.5 6320718491 Shader packs, clean room 39: shadow buffers, their samplers and shadowcomp
When the shadow pass runs, the pack gets real shadow buffers (spec 6.3):
shadowtex0 (cleared to 1.0 every frame), shadowtex1 (a copy, made only when
a program reads it) and shadowcolor0/1 (each with two sides, made only when
used, cleared to their ShadowClear colour, white by default), square at
shadowMapResolution within the device's limit.

Samplers: shadowtex0/watershadow, shadowtex1, `shadow` (shadowtex1 beside
watershadow) and the HW forms read the shadow map, through a linear depth
comparison when declared sampler2DShadow; shadowcolor/shadowcolor0/1 read
the colour buffers. In the shadow programs, buffer n is shadowcolor n and
colortex reads a stand-in. Without the shadow pass nothing changes: the
names read the stand-ins as before.

shadowcomp..shadowcomp99 run after the shadow pass and before prepare, each
drawing into the alt side of the shadowcolor buffers it writes and flipping
them after, as composite programs do with colortex (6.1.6). The shadow pass
itself draws nothing yet, so the map stays clear.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 5.2 (samplers), 6.1.6 and 6.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:54 +01:00
Ludovic MarieandClaude Opus 5.5 86d8d9e3a1 Shader packs, clean room 38: load the shadow programs
The shadow pass runs when some program reads a shadow buffer (shadow,
watershadow, shadowtex0/1, their HW forms, shadowcolor, shadowcolor0/1) or
shadow.enabled=true; shadow.enabled=false keeps it off (spec 2.5). Then
`shadow` (solid and cutout terrain), `shadow_water` falling back to
`shadow` (translucent terrain) and shadowcomp..shadowcomp99 load, in the
new Shadow and ShadowComp groups (2.1, 2.4). shadow_solid and shadow_cutout
are not used: the game's solid and cutout terrain is one draw.

Their output n writes shadowcolor n (6.3): draw buffer lists stop at the
first index past 1. translate_shadow gives a shadow program the shadow
camera's matrices as its model-view and projection (5.2). The game does not
draw any of them yet.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 2.4, 2.5, 5.2 and 6.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:34 +01:00
Ludovic MarieandClaude Opus 5.5 5dcbe222c9 Shader packs, clean room 37: shadow settings and the shadow camera
The stage files' shadow constants (shadowMapResolution, shadowDistance,
shadowDistanceRenderMul, shadowIntervalSize, sunPathRotation, shadowMapFov,
shadowNearPlane, shadowFarPlane, shadowHardwareFiltering[0|1] and the
shadowcolor0/1 Format, Clear and ClearColor) are read into Config::shadow
(spec 6.3, 7.1).

The shadow matrices are no longer a placeholder (OQ-6): the camera looks
from the shadow light, sits 128 blocks toward it from a point that moves in
steps of shadowIntervalSize along the shadow view's own axes, and projects
orthographically over shadowDistance each side (or in perspective with
shadowMapFov), between shadowNearPlane and shadowFarPlane.

sunPathRotation turns the sun and moon's path about the world's X axis:
sunPosition, moonPosition, shadowLightPosition and the shadow camera follow
it, and so do the sun, moon and stars a pack's gbuffers_skytextured and
gbuffers_skybasic draw.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 6.3 and 7.1; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:34 +01:00
Ludovic MarieandClaude Opus 5.5 749a2dc9bd Global level events reach a server's players; explosions play their packet's sound
ServerLevel.globalLevelEvent (an end portal opening, 1038; the dragon's
death roar, 1028) went to the Game's own audio without a position, which
is never forwarded: on the server thread, LAN and dedicated servers
nobody heard them. They now go to every guest as a global LevelEvent,
which a client plays two blocks from its camera towards the event
(LevelEventHandler.globalLevelEvent).

ClientPacketListener.handleExplosion plays the packet's explosionSound;
the client always played entity.generic.explode (a vanilla server's wind
charge sounded like TNT).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:30:43 +01:00
Ludovic MarieandClaude Opus 5.5 7bffc4c670 cargo horizon-mod test: run a mod's TOML test scripts headless
A mod's tests/<name>.toml scripts run in the sandbox the game uses, with no game
and no window. The runner plays the game's part (the player and clock the mod
reads, ticks, frames, chat lines, commands, a reload) and each step checks what
the mod did: its commands, chat lines, command feedback and errors, HUD panel
and corner, or that it was turned off. A failing step names itself and prints
expected against got. Each script runs on a scratch copy, so storage starts
empty and is removed afterwards.

horizon_mods::testing (feature runtime) holds TestGame and Script; the cargo
subcommand builds the mod (or takes --hmod), filters scripts by name, and fails
when one does. The template gains tests/hello.toml, coords-hud gains panel and
where scripts, and the sandbox tests run the example's scripts and check the
runner's failures, reload and expected stops. Scripts don't care what language
the mod is written in, so script mods (Lua) get the same runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:25:52 +01:00
Ludovic Marie 44ba133710 Merge branch 'feat/shader-packs-held-items' into feat/shader-packs-hand 2026-10-25 15:09:46 +01:00
Ludovic Marie aa04a30ad2 Merge branch 'feat/shader-packs-weather' into feat/shader-packs-held-items 2026-10-25 15:09:46 +01:00
Ludovic Marie 612024eff4 Merge branch 'feat/shader-packs-clouds' into feat/shader-packs-weather 2026-10-25 15:09:45 +01:00
Ludovic Marie c39579772c Merge branch 'feat/shader-packs-translucent-entities' into feat/shader-packs-clouds 2026-10-25 15:09:45 +01:00
Ludovic Marie 257654ef30 Merge branch 'feat/shader-packs-entities' into feat/shader-packs-translucent-entities 2026-10-25 15:09:44 +01:00
Ludovic Marie c4856e6feb Merge branch 'feat/shader-packs-alpha-test' into feat/shader-packs-entities 2026-10-25 15:09:44 +01:00
Ludovic Marie 90fe138e83 Merge branch 'feat/shader-packs-entity-spans' into feat/shader-packs-alpha-test 2026-10-25 15:09:44 +01:00
Ludovic Marie 15e0fcb3d8 Merge branch 'feat/shader-packs-sildurs' into feat/shader-packs-entity-spans 2026-10-25 15:09:43 +01:00
Ludovic Marie 07668d2d1e Merge branch 'feat/shader-packs-mip-current' into feat/shader-packs-sildurs 2026-10-25 15:09:43 +01:00
Ludovic Marie 202c421f6e Merge branch 'feat/shader-packs-biomes' into feat/shader-packs-mip-current 2026-10-25 15:09:43 +01:00
Ludovic Marie 90b5da76c4 Merge branch 'feat/shader-packs-normals' into feat/shader-packs-biomes 2026-10-25 15:09:42 +01:00
Ludovic Marie 57dca25dd6 Merge branch 'feat/shader-packs-block-ids' into feat/shader-packs-normals 2026-10-25 15:09:42 +01:00
Ludovic Marie dbc59979fb Merge branch 'feat/shader-packs-arena-storage' into feat/shader-packs-block-ids 2026-10-25 15:09:42 +01:00
Ludovic Marie 53db90264c Merge branch 'feat/shader-packs-sun-moon' into feat/shader-packs-arena-storage 2026-10-25 15:09:41 +01:00
Ludovic Marie 27f68454c1 Merge branch 'feat/shader-packs-sky' into feat/shader-packs-sun-moon 2026-10-25 15:09:41 +01:00
Ludovic Marie 1aad193be0 Merge branch 'feat/shader-packs-draw-uniforms' into feat/shader-packs-sky 2026-10-25 15:09:40 +01:00
Ludovic Marie c55f89ef98 Merge branch 'feat/shader-packs-mipmaps' into feat/shader-packs-draw-uniforms 2026-10-25 15:09:40 +01:00
Ludovic Marie 2617b67c79 Merge branch 'feat/shader-packs-timing' into feat/shader-packs-mipmaps 2026-10-25 15:09:39 +01:00
Ludovic Marie 15cf80921e Merge branch 'feat/shader-packs-water' into feat/shader-packs-timing 2026-10-25 15:09:39 +01:00
Ludovic Marie 60e8ec326a Merge branch 'feat/shader-packs-real-buffers' into feat/shader-packs-water 2026-10-25 15:09:39 +01:00
Ludovic Marie bf5c7e4ff2 Merge branch 'feat/shader-packs-custom-uniforms' into feat/shader-packs-real-buffers 2026-10-25 15:09:38 +01:00
Ludovic Marie aebc95fd00 Merge branch 'feat/shader-packs-run-globals' into feat/shader-packs-custom-uniforms 2026-10-25 15:09:37 +01:00
Ludovic Marie 754437fac6 Merge branch 'feat/shader-packs-terrain-draw' into feat/shader-packs-run-globals 2026-10-25 15:09:37 +01:00
Ludovic Marie 2f527aaaf2 Merge remote-tracking branch 'origin/main' into feat/shader-packs-terrain-draw
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-25 15:09:36 +01:00
Ludovic MarieandClaude Opus 5.5 0f2a497f02 Trapped chests give their signal, barrels show they're open
TrappedChestBlock.getSignal is the chest's opener count (at most 15);
TrappedChestBlockEntity.signalOpenCount updates the blocks around it and
below it when the count changes. GameLevel answered 0 for every trapped
chest. BarrelBlockEntity's openers set open=true for the first one and
open=false when the last one leaves; nothing set it.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:58:51 +01:00
Kacpherek 4cd1504513 Pregen: report what a stalled band waits for, and requeue lost light
When the band hasn't moved for 30 seconds the task logs how many chunks
of its first row are not loaded, being lit, unlit with no light job or
not post-processed, with the streaming stage counts, and queues light
again for unlit chunks whose neighbours are all loaded.
2026-10-25 14:40:37 +01:00
Kacpherek 35254c4f02 Pregen: keep an empty dedicated server from pausing while it runs
pause-when-empty-seconds paused a server with nobody online after a
minute, and a paused server skips its tick: post-processing stopped, so
the band waited forever on a row of lit chunks. A running /pregen now
counts as activity; the countdown starts once it is done.
2026-10-25 14:40:37 +01:00
Kacpherek 1506c1808a Add /pregen: generate, light and save an area ahead of the players
A Chunky-like command for single player and servers: /pregen start
<radius> [square|circle] [<x> <z>], status, pause, continue and cancel,
with progress (chunks, chunks/s, time left) in the log and to the player
who started it.

The area is swept strip by strip by a band of rows that streaming treats
like a player's area (Streaming::set_band). A row is done once its chunks
are lit and post-processed; the rows behind the band then unload and are
saved, so memory holds only the rows in flight. The band's chunks are
ranked for decoration row by row as they come within reach, so a task
decorates in the same order however its jobs are timed. Between ticks
the server waits on the workers' results and schedules the next jobs at
once instead of once a tick.
2026-10-25 14:40:37 +01:00
cristibctrandOpenAI Codex c756a372a3 Fix stale Windows resource pack overlays
Invalidate version 3 overlays that can omit untouched vanilla resources. Cover folder and ZIP pack cache recovery, and use a writable timestamp handle in the Windows cache test.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 15:32:57 +02:00
Ludovic MarieandClaude Sonnet 5.5 c5cfab5e07 Merge feat/server-dialog-screen into feat/styled-menu-titles
Clean merge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:25:14 +01:00
Ludovic MarieandClaude Sonnet 5.5 ec2f1b2a0c Merge feat/server-dialog-screen into feat/server-dialog-inputs
Clean merge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:25:14 +01:00
Ludovic MarieandClaude Sonnet 5.5 330a5205ae Merge feat/server-dialogs into feat/server-dialog-screen
Conflict: lib.rs module list (dialog_screen + discord_presence), both kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:24:52 +01:00
Ludovic MarieandClaude Sonnet 5.5 49dd31d36f Merge origin/main into feat/server-dialogs
Conflicts: lib.rs (dialog + discord_presence modules), packets/play.rs tests
(boss_event_round_trips + dialogs_round_trip), both kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:24:45 +01:00
Ludovic Marie e6b0f92427 Merge main 2026-10-25 14:21:57 +01:00
Ludovic MarieandClaude Opus 5.5 224b83c6a6 Shader packs, clean room 36: the hand
A pack with gbuffers_hand (or textured_lit, textured, basic; spec 2.3)
draws the first-person hand and held items after the opaque world, into
its own buffers and the world's depth, without blending and with the
entities' 0.1 alpha cut. The vertices are Horizon's hand geometry, read
as entities are; `gl_ProjectionMatrix` is the hand's own projection (the
hand's field of view and bob) with depth scaled by MC_HAND_DEPTH, while
`gbufferProjection` stays the world's (5.2). Horizon's own hand draw is
skipped only when the pack draws it.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:48:03 +01:00
Ludovic MarieandClaude Opus 5.5 18cac82d44 Shader packs, clean room 35: held item ids and light
`item.properties` (spec 4.1) gives `heldItemId` and `heldItemId2` the
id of the main and off hand's item: -1 for an item the file lacks or an
empty hand, 0 without the file (5.3). `heldBlockLightValue(2)` are the
light levels of the held items' blocks, as Horizon's dynamic lights
already compute them. With `oldHandLight` (on unless the pack turns it
off, OQ-21) the first id and light are those of the brighter hand.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:35:44 +01:00
Ludovic MarieandClaude Opus 5.5 173463acdf Shader packs, clean room 34: rain and snow
A pack with gbuffers_weather (or its fallbacks textured_lit, textured,
basic; spec 2.3) draws the game's rain and snow after translucent
terrain, blended, with vanilla's 0.1 alpha cut. The weather quads become
entity vertices with their UV animation applied on the CPU (the same
phase Horizon's weather shader computes; a test checks it against the
weather columns bit for bit), and rain and snow each read a copy of
their sprite, so the pack's `texture` repeats down the column as a
standalone texture would. The shared atlas gains COPY_SRC for that copy.

Without a pack nothing changes: Horizon's weather draw is skipped only
when the pack draws it.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:51:53 +01:00
Ludovic MarieandClaude Opus 5.5 abe7047d19 Shader packs, clean room 33: clouds
A pack with gbuffers_clouds draws Horizon's cloud geometry itself (spec
2.3, OQ-3: no fallback), blended, after the opaque world. Horizon's own
cloud draw is skipped then, also when the pack's program writes nothing
(BSL and Complementary hide vanilla clouds that way). Without a pack the
cloud draw is unchanged.

The translator now rewrites a bare `discard;` or `return;` to
`if (true) discard;` / `if (true) return;`, so code a pack leaves after
it stays reachable for naga ("Expression not visited"). This also fixes
Complementary Unbound's gbuffers_skytextured.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:32:10 +01:00
Ludovic MarieandClaude Opus 5.5 3bc38cebb4 YUNG jigsaws: the start is not counted, priority, depth and bounds rules run
From spec-yungsapi rev 3 §3, §3.1 and §3.2 (impl-questions IQ-2, IQ-3):
- the start piece does not count toward max_count;
- is_priority entries are tried first, in the pool's shuffled order, without
  a weighted draw; min_required_depth skips a pick (no draw) while the parent
  piece is shallower; ignore_bounds places without the free-space test (its
  box is still taken, IQ-12);
- the fallback was already shuffled only when reached.

The port no longer counts these three fields as lost. The layout harness gets
yung-probe and yung-probe2: the spec team's probe pack (built from the oracle
profile) carried as a mod, compared start by start with the oracle's census:
384 of 384 and 32 of 32 starts match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:17:21 +01:00
Ludovic MarieandClaude Opus 5.5 3a7d00a74c Shader packs, clean room 32: translucent entity parts and particles
A shader pack now draws particles with `gbuffers_particles` (falling
back to `gbuffers_textured_lit`, `gbuffers_textured`, `gbuffers_basic`)
and the entity parts that blend, a sneaking player's faded name tag and
entity shadows, with `gbuffers_entities_translucent` (falling back to
`gbuffers_entities`). Both blend with the translucent default unless
`blend.*` says otherwise; particles keep vanilla's alpha cut at 0.1.
Name tag text draws with `gbuffers_entities`, as cutout (2.3).

Particles draw after deferred, with translucent terrain, unless
`particles.ordering=before` or `particles.before.deferred=true`;
translucent entities draw before deferred unless
`separateEntityDraws=true` (3.2).

Without a shader pack nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:11:09 +01:00
Ludovic MarieandClaude Opus 5.5 ab289a2cf3 Shader packs, clean room 31: entities and block entities
A shader pack now draws entities with `gbuffers_entities` and block
entities with `gbuffers_block` (or their fallbacks, spec 2.3), after
terrain. Each run of the entity spans is one draw with its own
`entityId` or `blockEntityId` and `entityColor` (the hurt red, the
white flashes) in a per-draw slot. Horizon still draws everything else
(particles, cracks, beams, name tags, shadows, End portals, clouds) in
its own single entity draw, without the runs the pack drew.

The entity vertices are read again at set 1, so the pack gets the face
normal where a vertex has none (block entities), `at_tangent` along the
texture's u and `mc_midTexCoord`, as for terrain.

Entities and block entities draw without blending, with the loader's
alpha test at GREATER 0.1, as cutout (OQ-23); name tags and shadows,
which blend, stay Horizon's for now.

`HORIZON_SHADER_PACK_TIMING` no longer panics when a pass skips a whole
report window (no entities in view).

Without a shader pack nothing changes: the split only runs while a pack
draws entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 b5a791170d Shader packs, clean room 30: the loader's alpha test
Gbuffers programs get the loader's alpha test on output 0 when they
write `gl_FragData`/`gl_FragColor` (spec 3.2 `alphaTest.<program>`;
with `out` variables the pack tests alpha itself). `GbuffersInputs`
carries the geometry's default, none for terrain, water and the sky so
far; `alphaTest.<program>` replaces it for that program (`off`, or a GL
comparison and a reference).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:51:55 +01:00
Ludovic Marie 8d9e1e95ac Merge remote-tracking branch 'origin/main' into fix/remote-tnt
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 09:12:04 +01:00
Ludovic MarieandClaude Opus 5.5 58e184f5d5 Structure blocks harness: place the oracle's own pieces, split piece boxes from their surroundings
The mod world now takes each saved start whose pieces are all jigsaw pieces
as the oracle saved it (chunk_dump's HORIZON_SAVED_STARTS, through a new
GeneratorWorker::use_saved_start and PoolPiece::from_saved), so the blocks
are checked whatever the layout: Lithostitched lays jigsaws out its own way
(spec-lithostitched OPEN-L5) and the layout harness checks layouts apart.
Lithostitched's wrapper elements place their delegate; 1.21.1 block states
are read in 26.3's shape.

Counts are split between positions inside a piece's box and around them
(terrain adaptation, decoration), and every mismatch class is written to
.local/structure-layout/<profile>/blocks-report.json.

A CTOV profile carries Lithostitched and ChoiceTheorem's Overhauled Village
from a pack's jar folder (.local/structure-oracle/pack-jars).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:51:17 +01:00
Ludovic MarieandClaude Opus 5.5 aac2f3a903 Shader packs, clean room 29: entity spans
While a shader pack is loaded, building the entity geometry records where
each entity's vertices begin, with its render stage (entities, block
entities, particles, breaking cracks) and its `entityId` or
`blockEntityId` from the pack's `entity.properties` and
`block.properties` (65535 for a name the file lacks, 0 without the
file). The spans reach the renderer in `Frame::entity_spans`, for the
pack's per-entity draws that follow; Horizon's own entity draw stays one
call and without a pack nothing is recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 07:44:21 +01:00
Ludovic MarieandClaude Opus 5.5 f80f4e01e7 Structure blocks harness: the blocks Horizon's structure mods place against the structure oracle's
The clean-room structure oracle saved, for each start of a mod world, the
blocks of its box and every position around it that differs from the no-mod
world of the same seed. tools/test_structure_blocks_parity.py generates the
same chunks in full with Horizon twice (26.3 alone, and with the mods carried
by horizon-port-pack as players' packs are) and compares: a position counts
only where Horizon's no-mod world equals the oracle's (1.21.1 and 26.3 differ
elsewhere), and a mismatch is reported by its no-mod, expected and Horizon
blocks, so a missing processor rule reads as one line.

Better Dungeons (3 seeds, 96 complete starts): every structure is listed as
known, with the reason: the placeholder processors, the biome modifier and the
mod's own structure types are not implemented yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:39:52 +01:00
Ludovic MarieandClaude Opus 5.5 7b35ca2799 Shader packs, clean room 28: Sildurs Vibrant renders
Three things kept Sildurs Vibrant black or wrong:

- Matrix and array varyings (`varying mat3 tbnMatrix;`): wgpu's stage
  interfaces take only scalars and vectors, so the translator now carries
  each column or element as its own varying and copies them out of and
  into a private global around the pack's `main`.
- Switch options defined in an included file whose name is not a shader
  extension (`#include "shaders.settings"`) were not read, so every
  `program.<name>.enabled` that tested them was false and the composites
  were skipped (OQ-13).
- `fogStart`/`fogEnd` mixed the start of one of vanilla's two fogs with
  the end of the other, so on clear days fog began at the camera and
  Sildurs' water turned white. They now come as the pair of the fog that
  is complete nearer (OQ-36).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:35:43 +01:00
Ludovic MarieandClaude Opus 5.5 379692f355 Port YUNG's worldgen as written; delete the old YUNG conversion and assembler
Clean-room (spec-yungsapi.md §2, §3, §5; no mod source read; the legacy
files were deleted unread). horizon-port-pack's new `yung` module carries
`yungsapi:yung_jigsaw` structures and `yungsapi:yung_single_element` pool
elements as the mods write them, so players' packs reach the YUNG draws of
the jigsaw assembler. Fields whose behaviour the specification leaves open
(adapt_noise, max_y/min_y, enhanced terrain, element conditions, modifiers,
priorities, depths, ignore_bounds) are counted as lost by name. YUNG's
other structure, placement and processor types go to the general code
(unsupported or dropped) until the specification covers them.

Deleted: legacy_structure_mods/yung.rs, horizon-world's structure/yung.rs,
the `horizon:yungsapi` keys and `horizon:disables`, which nothing writes
now. Two test initializers of the removed field (enhanced_terrain.rs,
piece_limited.rs) were removed by line from the compiler's error positions.

The layout harness now carries the mods through `horizon-port-pack carry`:
82 of 84 Better Dungeons starts match the oracle piece for piece, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:19:43 +01:00
Ludovic MarieandClaude Opus 5.5 9a8453c612 Shader packs, clean room 27: sample mip chains only while current
A program sampled every level of a buffer that has mipmaps, even after a
later program had rewritten the buffer: the levels below the first were
stale. Complementary's FXAA, which samples its colour buffer with implicit
derivatives inside branches, picked those levels at edges and drew black
dots along every edge. A buffer now binds its whole chain only while the
chain matches its content, and its first level otherwise (6.1.7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:32 +01:00
Ludovic MarieandClaude Opus 5.5 bf59c0c4f7 Shader packs, clean room 26: biome uniforms and constants
Custom uniform expressions and programs now see the biome at the camera:
`biome`, `biome_category`, `biome_precipitation`, `temperature` and
`rainfall`, and the `BIOME_*`, `CAT_*` and `PPT_*` constants (spec 3.4).
Before, `biome` and every `BIOME_*` read 0, so a test such as
`biome == BIOME_PALE_GARDEN` held everywhere: Complementary drew the pale
garden's fog over every world, and BSL treated every biome as cold, dry,
swampy and so on at once.

OQ-11: `biome` is the index in the game's biome registry (vanilla's
biomes sorted by identifier); names from before the 1.18 renames map to
their successors; a `BIOME_` name that matches no biome reads -1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:32 +01:00
Ludovic Marie 30c4b5e80d Merge remote-tracking branch 'origin/main' into sh-top 2026-10-25 06:08:23 +01:00
Ludovic Marie 066f07968b Merge remote-tracking branch 'origin/refactor/port-pack-legacy-structure-mods' into feat/yung-port-as-written 2026-10-25 05:38:20 +01:00
Ludovic MarieandClaude Opus 5.5 732efe69bb Shader packs, clean room 25: terrain normals and tangents
Flat terrain's vertex stage reads its quad's first three corners from the
arena page (bound per page at set 3, beside mc_Entity's table) and gives
gl_Normal / vaNormal the face normal and at_tangent the tangent along
the texture's u, with w the handedness of bitangent = cross(tangent,
normal) (spec 5.1). The pack's terrain and water pipelines cull back
faces as the game's own terrain does (clockwise front faces, rows being
flipped to GL's): water's surface no longer shows its downward copy.

Flat draws call a hook per arena page (Flat::draw_with); the arena lists
its page buffers.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
5.1; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:16:38 +01:00
Ludovic MarieandClaude Opus 5.5 441f749c25 Shader packs, clean room 24: mc_Entity from block.properties
block.properties (spec 4.1) is read: `block.<id>=` names with optional
namespaces and state filters; the most specific matching entry wins.
Terrain vertices carry their sprite layer, not their block, so the game
builds a table of the block sprite layers: each takes the id of the block
named like its sprite, else the id most blocks drawing it share; y is 1
on the water and lava sprites. Flat terrain reads it from a storage
buffer at set 3; other draws and packs without the file read (-1, -1).
Building the table takes 14 ms for BSL, 39 ms for Complementary.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
4.1, 5.1, 5.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 b940754555 Terrain vertex arena readable as storage where flat draws run
The terrain vertex arena's pages also get STORAGE usage on devices that
run flat draws (vertex storage and compute), so a shader pack's terrain
program can read the other corners of a vertex's quad to give packs a
face normal and tangent. Horizon's own pipelines are unchanged; the A/B
in the PR shows no frame-time change without a pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:23:38 +01:00
Ludovic MarieandClaude Opus 5.5 36f68ab838 Server dialogs: text and boolean inputs
A dialog's text inputs are edit boxes (labelled above, CommonLayouts.
labeledElement; width, max length and initial value from the dialog) and
its boolean inputs are checkboxes with their label. A click focuses a box
or toggles a box, Tab and Shift+Tab move between the text inputs, the
keys, shortcuts and typed text go to the focused one. What they hold
fills the command templates and custom actions' payloads.

With this, AuthMe's register and login dialogs work: players type their
password and join instead of being kicked after 30 s.

horizon-ctl's `type` types into the focused input of an open dialog (the
first one when none is), in configuration too. server_e2e's dialog check
now types a number and ticks a box, and checks the command the template
makes of them and the payload of a custom action.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:05:25 +01:00
Ludovic MarieandClaude Opus 5.5 230730d1f5 Shader packs, clean room 23: the sun and moon (gbuffers_skytextured)
gbuffers_skytextured (or gbuffers_textured, gbuffers_basic) draws the
frame's sun and moon quads, with their render stages, `texture` reading
the shared atlas their sprites live in. The sky's parts draw in vanilla's
order (disc, sunset, sun, moon, stars, void), in one render pass while
consecutive programs write the same buffers. The sun and moon add to the
sky (SRC_ALPHA, ONE) unless blend.<program> says otherwise. Horizon's
world pass skips its own sun and moon when the pack draws them; a part
without a program stays Horizon's.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
2.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:02:13 +01:00
Ludovic MarieandClaude Opus 5.5 7d6583dc4d Shader packs, clean room 22: the sky (gbuffers_skybasic)
The pack's gbuffers_skybasic (or gbuffers_basic) draws vanilla's sky
geometry, as Horizon's own sky builds it: the disc at y 16, the sunset
fan while the sun is low, the stars and the void disc, each with its
render stage. One static vertex buffer; the frame's colours and rotations
sit in a small uniform the vertex inputs read. The sky draws after the
world copy and before terrain, at the far plane: only where nothing else
drew, without writing depth. It blends with the translucent default unless
blend.<program> says otherwise. Horizon's world pass then skips its own
sky disc, stars and void. The sun and moon stay Horizon's for now
(gbuffers_skytextured is next): the pack's sky covers them.

load_post takes the game's inputs per geometry (GameInputs);
GbuffersInputs gains statements run after the pack's main.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
2.1, 2.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:45:24 +01:00
Ludovic MarieandClaude Opus 5.5 1fdb119587 Server menus keep their styled titles
A server's open_screen title is a text component, and container screens
draw it as AbstractContainerScreen does: in the label grey, under the
component's own colours, bold, italics and fonts, with the § codes a
literal holds. Horizon flattened it to a plain string, so a plugin's
gold and bold "Server Selector" menu showed in plain grey (#1645).
Chests, hoppers, dispensers, droppers, brewing stands and the other
workstations now draw the server's title. Before, the workstations
ignored it and showed their default name.

horizon-ctl's `screen` reports the title span by span (text, colour,
bold, italic). server_e2e's new `titles` check names a chest on the
vanilla server with a styled CustomName, opens it, and checks the title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:30:04 +01:00
Ludovic MarieandClaude Opus 5.5 a7f8650c6b Server dialogs: the dialog screen, its buttons and their actions
A server's show_dialog (configuration and play) opens vanilla's
DialogScreen instead of being dropped: the header with the title and the
warning button, the message bodies in a scrolling column, the action
buttons packed into columns (MultiButtonDialogScreen) or side by side in
the footer (notice, confirmation). A button runs its click event: a
custom action goes back as custom_click_action, a command runs (in play;
in configuration vanilla only warns), show_dialog opens another dialog,
copy_to_clipboard copies. after_action closes the screen, leaves it, or
opens the WaitingForResponseScreen. The warning button opens the
"custom screen" warning, whose Disconnect leaves the server. clear_dialog
closes it, and so do the level loading screen and a disconnect. The world
and HUD blur behind it as behind any in-game screen.

Inputs come next; here they are not drawn and keep their initial values.

tools/server_e2e.py is a new harness: a vanilla 26.3 dedicated server and
one Horizon client. Its dialog check shows a dialog with /dialog show,
clicks a command button (the server's trigger), a custom action (the
server's debug log shows its payload) and a wait_for_response one, and
clears a dialog with /dialog clear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:29:45 +01:00
Ludovic MarieandClaude Opus 5.5 b9b47f12da Shader packs, clean room 21: per-draw uniforms (renderStage)
A gbuffers program's renderStage, entityId, blockEntityId and entityColor
(spec 5.3, updated per draw) move to a block of their own at set 2 that
the game picks per draw with a dynamic offset: one slot per render stage.
Terrain draws as TERRAIN_SOLID, translucent terrain as
TERRAIN_TRANSLUCENT. The terrain test pack paints any other stage red.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
5.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:29:21 +01:00
Ludovic MarieandClaude Opus 5.5 5e348aa479 Shader packs, clean room 20: buffer mipmaps
`const bool <buffer>MipmapEnabled = true;` in a deferred, composite or
final program's fragment stage now regenerates that buffer's mip chain
right before the program (6.1.7), on the side programs read, and only
when the buffer changed since its last chain this frame. Each level is
the average of the 2x2 texels above it; levels attach one at a time and
programs sample all of them. Unfilterable buffers (integer, 32-bit
float) get none, with a note.

BSL's bloom read every tap from the full-size buffer before; with its
chain composite4 drops from 0.38 to 0.29 ms at 720p on the Mac mini
(HORIZON_SHADER_PACK_TIMING, the chains included), and blurs as intended.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
section 6.1.7; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:03:54 +01:00
Ludovic MarieandClaude Opus 5.5 1d90d72a64 Server dialogs: the packets and the dialog model
show_dialog and clear_dialog in configuration and play, and
custom_click_action, the answer, in both. Dialog.DIRECT_CODEC as
horizon_client::dialog: the five dialog types, their bodies, inputs and
actions with vanilla's defaults, the values inputs hold, and
Action.createAction (a command template's $(key) substitution with
escapeWithoutQuotes, a custom action's payload of every input's tag).

Nothing uses them yet: the screen comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:59:28 +01:00
Ludovic MarieandClaude Opus 5.5 b6345fc61d Shader packs, clean room 19: GPU time of each pass (developers' aid)
HORIZON_SHADER_PACK_TIMING=1 puts timestamps around every pass of a
shader pack, one frame in 30, and logs each pass's median every 30
timed frames. The frame after a timed one waits for the GPU before
reading them (Metal resolves timestamps without waiting for the passes
that write them). Passes overlap on tiled GPUs, so a pass is charged
from the later of its beginning and the previous pass's end.

This is what attributes a pack's cost to its own programs or to ours
when the machine is shared and FPS comparisons are noisy.

Clean-room: Horizon's own tooling, no spec section; no other loader's
code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:57:15 +01:00
Ludovic MarieandClaude Opus 5.5 3e2c30a852 Shader packs, clean room 18: translucent terrain drawn by the pack
With a pack whose gbuffers_water (or a fallback down gbuffers_terrain's
chain) loads, translucent terrain (water, stained glass) is drawn by it
after the deferred programs and before the composites, in place, sorted
back to front as the game sorts it, and Horizon's own translucent
terrain draw is skipped.

- blend.<program> (off, or four GL factors); translucent terrain
  defaults to SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA, and
  only blendable buffer formats blend.
- depthtex1/depthtex2: depth without translucent terrain, a copy taken
  just before it when a later program reads them (before it, depthtex0
  itself). The hand is drawn after the pack, so depthtex2 = depthtex1.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 2.3, 3.2 (blend), 6.2 (depthtex1/2); no other loader's
code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:47:14 +01:00
Ludovic MarieandClaude Opus 5.5 a2ec3fc791 Shader packs, clean room 17: real packs' buffers and samplers
- RGBA16 and R11F_G11F_B10F buffers get their own formats where the device has them
  (16-bit normalized render targets need the adapter's format features): the float stand-in
  kept NaN and values past 1, and BSL's TAA history went black for good.
- Textures share one sampler variable per kind (linear, nearest, repeat, nearest-repeat,
  comparison): devices have as few as 16 sampler slots, Complementary's terrain program
  declares 23 textures.
- A gbuffers pass reading the depth buffer or a buffer it draws into reads a blank texture
  instead (a feedback loop: undefined in GL, refused by wgpu, and a crash here).
- A bind group layout that fails validation skips the program with the error's details
  instead of ending the game.
- shadowModelView / shadowProjection (and inverses) for programs that read them before the
  shadow pass exists: an orthographic camera 160 blocks each side, toward the shadow light.
- HORIZON_SHADER_PACK_SKIP=a,b leaves programs out (a developers' aid to bisect a pack).

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, sections 6.1, 6.7,
5.2; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 505e8e960f Shader packs, clean room 16: custom uniforms
`uniform.<type>.<name>` and `variable.<type>.<name>` of shaders.properties (3.4): an
expression parser with C precedence, the function table (math, vectors, `if`, `between`,
`equals`, `smooth`, `random`...), evaluated on the CPU once per frame in file order after the
frame's own uniforms. `smooth` keeps its state per call site and halves the distance to its
target every fade time (OQ-26 working answer). A name neither the frame nor the pack defines
reads 0 and is listed once in the log.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, section 3.4; no other
loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 6c8880fbd8 Shader packs, clean room 15: globals initialized at run time, switch options
naga keeps only constant initializers of globals, so every initialized global that is not
`const` (and every conditional one) gets its value from a function of ours that main calls
first, one per declarator, converting as GLSL's initialization does (`vec2 v = atlasSize`).
`vec3 a = x, b = y;` no longer loses `b`.

Program-enable expressions (3.3) now see the pack's switch options that are on by default
(8.1): `#define NAME` with nothing after it, tested with `#ifdef`/`#ifndef` anywhere in the
pack (OQ-13 working answer). BSL's `deferred` (ambient occlusion) and its TAA ran with options
off before.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, sections 3.3, 8.1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:00:40 +01:00
Ludovic MarieandClaude Opus 5.5 1fa46979c8 Server clients: primed TNT falls and burns its fuse by itself between updates
PrimedTnt keeps Entity's NO_OP InterpolationHandler and ticks on a client
too: a position packet puts it there at once, its own tick moves it on
(gravity, collisions, bounces, drag) and burns the fuse down, and a client
discards it when the fuse runs out. Horizon interpolated it towards each
update instead, so it trailed about two ticks behind the server.

lan_e2e's falling check now drops primed TNT too and checks that the
guest has it exactly where its fall from rest puts it for the fuse the
server sent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:57:26 +02:00
Ludovic MarieandClaude Opus 5.5 0f9de6dbb0 Shader packs, clean room 14: terrain drawn by the pack's program
With a pack whose gbuffers_terrain (or a fallback) loads, solid and
cutout terrain is drawn by it into the pack's buffers, in place, between
prepare and deferred, and Horizon's own solid terrain draw is skipped.

- depthtex0 is now a Depth32Float attachment: the blit writes the
  world's depth there (GL window depth, GL rows), terrain tests and
  writes against it, so entities and terrain occlude each other; later
  programs sample it as before.
- The vertex glue reads Horizon's TerrainVertex on both draw paths
  (instanced, and flat with the page tables in bind group 1) and gives
  atlas coordinates, lightmap coordinates in 0-240, the vertex colour,
  mc_midTexCoord at the sprite's centre and mc_Entity = -1. Normals,
  tangents and block ids come later.
- texture/gtexture/tex sample the block atlas, lightmap the lightmap;
  atlasSize is set.
- Occlusion culling is off while a pack draws terrain (it reads the
  depth of Horizon's terrain draw), and flat draws stay on.
- Uniforms a program reads that are not provided yet are reported.
- Program building is shared by quads and terrain (build_pass, bind).
- terrain-test: Horizon's own pack for this slice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:39:28 +02:00
Ludovic MarieandClaude Opus 5.5 3d4b63c19e wasmtime 48 LTS: one wasm-tools version for the runtime, packing and the SDK
wasmtime 36 read WebAssembly with wasm-tools 0.236, wit-component (cargo
horizon-mod, the sandbox tests) used 0.239 and the SDK's wit-bindgen 0.247:
three copies of wasmparser, wasm-encoder, wit-parser and wasm-metadata.
wasmtime 48, the next long-term-support line (fixes until August 2028),
wit-component 0.254 and wit-bindgen 0.60 all use 0.254. No code change;
horizon-mods now needs Rust 1.95.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:25:10 +02:00
281 changed files with 23272 additions and 4763 deletions

No files matched your search

+2 -1
View File
@@ -29,7 +29,8 @@ env:
jobs:
audit:
runs-on: [self-hosted, horizon-game]
# 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
steps:
- name: 03:00 in Paris
+5 -1
View File
@@ -23,7 +23,11 @@ env:
jobs:
parity-main:
runs-on: [self-hosted, horizon-game]
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
- uses: actions/checkout@v4
+21 -2
View File
@@ -36,11 +36,30 @@ jobs:
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
env:
BASE_SHA: ${{ inputs.base }}
run: |
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
set -euo pipefail
if git rev-parse --verify -q origin/main >/dev/null \
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
bash "$RUNNER_TEMP/vanilla-data.sh"
else
bash tools/ci/vanilla-data.sh
fi
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
fi
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
+46 -1
View File
@@ -79,9 +79,50 @@ jobs:
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
build:
exporters:
needs: decide
if: needs.decide.outputs.release == 'true'
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: [decide, exporters]
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
@@ -220,6 +261,10 @@ jobs:
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
+3
View File
@@ -31,3 +31,6 @@
/.horizon-cache/
/crash-reports/
/debug/
# Release-only compiled original exporter bundle.
/exporters/
Generated
+253 -310
View File
File diff suppressed because it is too large. Load diff
+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", "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", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
+1 -1
View File
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
+10
View File
@@ -208,6 +208,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
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.tick(t);
for _ in 0..FRAMES_PER_TICK {
@@ -228,8 +231,15 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
});
}
let shapes = mods.hud_shapes().count();
if shapes > 0 {
report.output.push(format!("drew {shapes} HUD shape(s)"));
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
+68 -1
View File
@@ -6,12 +6,15 @@
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
//! `tests/*.toml` scripts in the sandbox, with no game.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
mod check;
mod registry;
@@ -36,9 +39,10 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -97,6 +101,10 @@ fn new(args: &[String]) -> Result<(), String> {
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
(
"tests/hello.toml",
include_str!("../template/tests/hello.toml"),
),
];
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
@@ -270,6 +278,63 @@ fn check(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
None => build(!args.iter().any(|a| a == "--debug"))?,
};
let filter = args
.iter()
.enumerate()
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
.map(|(_, a)| a.as_str());
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
.map_err(|e| {
format!(
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
)
})?
.filter_map(|e| Some(e.ok()?.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
.collect();
scripts.sort();
if scripts.is_empty() {
return Err("no tests/*.toml to run".into());
}
println!(
"running {} scripts against {}",
scripts.len(),
hmod.display()
);
let mut failed = 0;
for path in &scripts {
let result = std::fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| Script::parse(&text))
.and_then(|script| {
let mut game = TestGame::load(&hmod)?;
script.run(&mut game)?;
Ok(script.steps.len())
});
match result {
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
Err(e) => {
failed += 1;
println!("test {} ... FAILED\n {e}", path.display());
}
}
}
let passed = scripts.len() - failed;
if failed > 0 {
return Err(format!("{failed} failed, {passed} passed"));
}
println!("test result: ok. {passed} passed");
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
@@ -312,6 +377,8 @@ mod tests {
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
@@ -0,0 +1,14 @@
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
# steps happen in order, and `expect` checks what the mod showed during the step.
# Every key: docs/mods.md, "Testing a mod".
[[step]]
expect.commands = ["hello"]
[[step]]
command = "hello world"
expect.chat = ["Hello world! (1 so far)"]
[[step]]
command = "hello again"
expect.chat = ["Hello again! (2 so far)"]
+3 -1
View File
@@ -18,10 +18,12 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-server-files = { path = "../horizon-server-files" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
lid: &|_| open,
sign: &|_| sign.as_ref(),
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
};
let mut verts = Vec::new();
@@ -7,7 +7,7 @@
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
use crate::pack_screen_fixture;
use horizon_client::{
chat::{ChatAction, ChatScreen, Commands},
chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
command::vanilla,
hud::HudAtlas,
lang,
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
history.len(),
w,
h,
ChatAbilities::new(ChatVisibility::Full),
);
screen.auto_suggestions = case["autoSuggest"] == true;
let mut closed = false;
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
atlas,
comp(&m["text"]),
indicator,
horizon_client::chat::ChatSource::Client,
m["at"].as_u64().unwrap_or(0),
false,
);
@@ -598,6 +598,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
let _ = writeln!(out, " removed {reason}");
return;
}
let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
let p = m.position;
let v = m.velocity;
let _ = write!(
@@ -977,6 +978,7 @@ pub fn main() -> horizon_client::Result<()> {
}
// Set once the brain exists (below).
x if x.starts_with("cooldown=") => {}
"dead" => m.health = 0.0,
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
@@ -1097,6 +1099,7 @@ pub fn main() -> horizon_client::Result<()> {
}
};
}
let mut dropped_leads = 0;
for t in 1..=s.ticks {
world.game_time += 1;
if s.daycycle {
@@ -1208,6 +1211,16 @@ pub fn main() -> horizon_client::Result<()> {
if !alive {
m.removed = true;
}
// Apply the same detach/drop event as Game::break_leash.
for event in &events[from..] {
if let MobEvent::LeashBreak { mob, drop, .. } = event
&& let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
&& horizon_client::mobs::leash::remove(m).is_some()
&& *drop
{
dropped_leads += 1;
}
}
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
villager_events(
&mut mobs,
@@ -1427,6 +1440,9 @@ pub fn main() -> horizon_client::Result<()> {
world.game_time,
);
}
if s.name.starts_with("leash_death_") {
let _ = writeln!(out, "{t} leads {dropped_leads}");
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
let _ = writeln!(
@@ -730,6 +730,10 @@ pub struct Scene<'a> {
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1279,6 +1283,13 @@ impl BlockEntityRenderer {
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
SkullType::Player => ("player_head", "entity/player/slim/steve"),
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
} else {
texture
};
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1886,6 +1897,7 @@ mod tests {
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
// Compare the cached world path with the original model renderer,
+34
View File
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
}
}
/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
/// water cauldron lowers it a level; a powder snow cauldron becomes a water
/// cauldron of its level first, then lowers.
pub fn extinguished(cauldron: &BlockState) -> BlockState {
if cauldron.name == "minecraft:powder_snow_cauldron" {
let mut water = cauldron.clone();
water.name = "minecraft:water_cauldron".into();
lowered(&water)
} else {
lowered(cauldron)
}
}
/// `LayeredCauldronBlock.lowerFillLevel`.
fn lowered(cauldron: &BlockState) -> BlockState {
let level: u8 = cauldron
@@ -296,3 +309,24 @@ pub fn interact(
_ => pass(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
let water = |l| state("minecraft:water_cauldron", Some(l));
assert_eq!(extinguished(&water(3)), water(2));
assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
// Powder snow turns to water of its level, then lowers.
assert_eq!(
extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
water(2)
);
assert_eq!(
extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
state("minecraft:cauldron", None)
);
}
}
+167 -10
View File
@@ -19,6 +19,92 @@ mod tests;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChatVisibility {
Full,
System,
Hidden,
}
impl ChatVisibility {
/// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
pub fn from_option(value: u8) -> Self {
match value {
0 => Self::Full,
1 => Self::System,
_ => Self::Hidden,
}
}
}
/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
/// derives it from the visibility alone (`ChatRestriction`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChatAbilities {
pub send_messages: bool,
pub send_commands: bool,
pub receive_player_messages: bool,
pub receive_system_messages: bool,
}
impl ChatAbilities {
/// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
/// chat, commands, player messages and system messages;
/// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
pub fn new(visibility: ChatVisibility) -> Self {
match visibility {
ChatVisibility::Full => Self {
send_messages: true,
send_commands: true,
receive_player_messages: true,
receive_system_messages: true,
},
ChatVisibility::System => Self {
send_messages: false,
send_commands: true,
receive_player_messages: false,
receive_system_messages: true,
},
ChatVisibility::Hidden => Self {
send_messages: false,
send_commands: false,
receive_player_messages: false,
receive_system_messages: false,
},
}
}
/// `visibleMessagesFilter`: the client's own lines always show.
pub fn shows(&self, source: ChatSource) -> bool {
match source {
ChatSource::Player => self.receive_player_messages,
ChatSource::Server => self.receive_system_messages,
ChatSource::Client => true,
}
}
/// Whether the chat screen may send `input` (`hasAllowedInput`).
pub fn allows(&self, input: &str) -> bool {
if input.starts_with('/') {
self.send_commands
} else {
self.send_messages
}
}
}
impl Default for ChatAbilities {
fn default() -> Self {
Self::new(ChatVisibility::Full)
}
}
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
/// player messages and the server's, while the client's own always show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChatSource {
Player,
Server,
Client,
}
/// The chat options (doubles, as vanilla keeps them).
#[derive(Clone, Copy, Debug)]
pub struct ChatOptions {
@@ -71,6 +157,7 @@ struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
source: ChatSource,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -105,6 +192,8 @@ pub struct Chat {
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -114,16 +203,32 @@ impl Chat {
fn max_width(&self) -> i32 {
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
}
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
/// message the Chat option hides is dropped, unlogged.
pub fn add(
&mut self,
atlas: &HudAtlas,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
// ChatComponent.logChatMessage: every message is in the log.
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
fn add_message(
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
if self.log {
println!("[CHAT] {}", content.plain());
@@ -132,16 +237,17 @@ impl Chat {
added: ticks,
content,
indicator,
source,
};
self.add_lines(atlas, &message, focused);
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
let content = self.displayed(&message.content);
for line in atlas.font.wrap_components(&content, width) {
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -167,16 +273,38 @@ impl Chat {
content.strip_legacy()
}
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(atlas, m, false);
self.add_lines(font, m, false);
}
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
if self.abilities != abilities {
self.abilities = abilities;
self.rewrap(font);
}
}
/// Whether a message from `source` would show (`visibleMessagesFilter`).
pub fn shows(&self, source: ChatSource) -> bool {
self.abilities.shows(source)
}
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
@@ -437,6 +565,8 @@ pub struct ChatScreen {
allow_suggestions: bool,
keep_suggestions: bool,
pub auto_suggestions: bool,
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -482,6 +612,7 @@ impl ChatScreen {
history_len: usize,
width: i32,
height: i32,
abilities: ChatAbilities,
) -> Self {
let mut input = TextField::new(4, height - 12, width - 4, 12);
input.max_length = 256;
@@ -505,6 +636,7 @@ impl ChatScreen {
allow_suggestions: false,
keep_suggestions: false,
auto_suggestions: true,
abilities,
};
s.update_command_info(m, c);
s
@@ -522,7 +654,8 @@ impl ChatScreen {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
*self = Self::new(m, c, &value, self.history_pos, width, height);
let abilities = self.abilities;
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
self.auto_suggestions = auto;
}
}
@@ -604,6 +737,14 @@ impl ChatScreen {
self.pending = Some((range.0, list));
self.usage_width = 0;
self.usage_position = 0;
// CommandSuggestions.updateCommandInfo: the Chat option may take
// the messages.
if !self.abilities.send_messages {
let line =
Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
self.usage_width = m.width(&line);
self.usage.push(line);
}
} else {
self.awaiting = None;
self.pending = None;
@@ -708,6 +849,15 @@ impl ChatScreen {
}
self.usage.extend(entries);
}
// CommandSuggestions.updateUsageInfo: the Chat option may take the
// commands, under the usage. (Vanilla also counts a command with a
// message argument, `/say hi`, as a message; Horizon's parse keeps no
// argument kinds, so only a bare message does.)
if !self.abilities.send_commands {
self.usage.push(
Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
);
}
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
self.usage_position = if self.usage.is_empty() {
0
@@ -842,7 +992,14 @@ impl ChatScreen {
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
+114 -5
View File
@@ -2,6 +2,11 @@ use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The Chat option's default: no restriction.
fn full() -> ChatAbilities {
ChatAbilities::new(ChatVisibility::Full)
}
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
registries: &registries,
};
let history = vec!["first message".to_owned(), "latest message".to_owned()];
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
let mut screen = ChatScreen::new(
&m,
&commands,
"unfinished draft",
history.len(),
640,
360,
full(),
);
for (key, expected) in [
(ArrowUp, "latest message"),
(ArrowUp, "first message"),
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -327,3 +340,99 @@ fn a_server_component_keeps_its_click_and_hover_events() {
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
}
/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
#[test]
fn the_chat_option_decides_what_may_be_sent() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let enter = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
};
for (visibility, chat, command) in [
(ChatVisibility::Full, true, true),
(ChatVisibility::System, false, true),
(ChatVisibility::Hidden, false, false),
] {
let abilities = ChatAbilities::new(visibility);
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
assert!(allowed, "{visibility:?} sent {sent}");
assert_eq!(sent, line);
}
ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
ChatAction::Close => panic!("Enter does not close the chat"),
}
}
}
}
/// The restriction the option sets is a usage line: red, under the input, for
/// a command the Chat option takes and for a message it takes.
#[test]
fn restricted_input_says_why_it_will_not_send() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::<String>::new());
// The font has no language files here, so the line is its key.
assert_eq!(
usage(ChatVisibility::System, "hi"),
["chat_screen.messages_not_allowed"]
);
// A command the option allows says nothing about the restriction.
assert!(
!usage(ChatVisibility::System, "/time set day")
.iter()
.any(|line| line == "chat_screen.commands_not_allowed")
);
assert!(
usage(ChatVisibility::Hidden, "/time set day")
.iter()
.any(|line| line == "chat_screen.commands_not_allowed")
);
}
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
/// on arrival is dropped; one already in the chat leaves it while the option
/// hides its kind and comes back when it shows it again.
#[test]
fn the_chat_option_filters_the_lines_both_ways() {
let font = font();
let mut chat = Chat::default();
let says = |chat: &mut Chat, text: &str, source| {
chat.add_message(&font, Text::literal(text), None, source, 0, false);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "player", ChatSource::Player);
says(&mut chat, "server", ChatSource::Server);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(shown(&chat), ["server"]);
says(&mut chat, "dropped", ChatSource::Player);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
says(&mut chat, "client", ChatSource::Client);
assert_eq!(shown(&chat), ["client"]);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
+18
View File
@@ -92,6 +92,13 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result<bool, Self::Error> {
self.blocks.is_suffocating(chunk, block, p)
}
fn may_reach_out(
&mut self,
chunk: Self::Chunk,
section_y: i32,
) -> std::result::Result<bool, Self::Error> {
self.blocks.may_reach_out(chunk, section_y)
}
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
}
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
// The game's own tree is left as it was.
assert!(vanilla::game_dispatcher().find("where").is_none());
}
#[test]
fn horizon_commands_are_off_unless_the_server_turns_them_on() {
// By default the game runs vanilla's tree: /help lists no /pregen.
assert!(vanilla::game_dispatcher().find("pregen").is_none());
for dev in [false, true] {
let vanilla_tree = vanilla::game_tree(dev, false);
assert!(vanilla_tree.find("pregen").is_none());
let d = vanilla::game_tree(dev, true);
let parse = d.parse("pregen start 100 circle", &reg());
assert_eq!(parse.executable().unwrap().1, "pregen.start");
// Vanilla's commands are the same with or without it.
assert_eq!(
d.nodes[0].children.len(),
vanilla_tree.nodes[0].children.len() + 1
);
}
}
+62 -5
View File
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
}
/// The tree the game runs: a release integrated server's, or with
/// `HORIZON_DEV_COMMANDS` set the development one
/// (SharedConstants.DEBUG_DEV_COMMANDS).
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
/// the server turned them on (`set_horizon_commands`).
pub fn game_dispatcher() -> &'static Dispatcher {
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
dispatcher()
} else {
release_dispatcher()
game_tree(
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// Whether this process's server has Horizon's own commands (`/pregen`). Off
/// by default, so `/help` and the command tree are vanilla's; a dedicated
/// server turns them on with `horizon-pregen=true` in server.properties, the
/// game with `--pregen` (docs/pregeneration.md).
static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
/// returns from now on.
pub fn set_horizon_commands(on: bool) {
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
/// ignores keys it doesn't know).
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
static HORIZON: [std::sync::OnceLock<Dispatcher>; 2] =
[const { std::sync::OnceLock::new() }; 2];
match (dev, horizon) {
(true, false) => dispatcher(),
(false, false) => release_dispatcher(),
(dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
let mut d = build_with(dev);
horizon_commands(&mut d, 0);
d
}),
}
}
/// Commands vanilla doesn't have, after its own: `/pregen`
/// (docs/pregeneration.md).
fn horizon_commands(d: &mut Dispatcher, root: usize) {
let pregen = d.literal(root, "pregen");
d.requires(pregen, GAMEMASTERS);
let start = d.literal(pregen, "start");
let radius = d.argument(
start,
"radius",
ArgType::Integer {
min: 0,
max: 30_000_000,
},
);
d.executes(radius, "pregen.start");
for shape in ["square", "circle"] {
let shape = d.literal(radius, shape);
d.executes(shape, "pregen.start");
let center = d.argument(shape, "center", ArgType::ColumnPos);
d.executes(center, "pregen.start");
}
for (word, id) in [
("pause", "pregen.pause"),
("continue", "pregen.continue"),
("cancel", "pregen.cancel"),
("status", "pregen.status"),
] {
let w = d.literal(pregen, word);
d.executes(w, id);
}
}
pub fn dispatcher() -> &'static Dispatcher {
+64 -3
View File
@@ -83,6 +83,60 @@ pub enum BlockEntity {
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
note_block_sound: Option<String>,
custom_name: Option<horizon_world::nbt::Tag>,
) -> Self {
let skin = profile.as_ref().map(SkullSkin::of);
Self {
profile,
note_block_sound,
custom_name,
skin,
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
@@ -238,6 +292,9 @@ impl BlockEntity {
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
}
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -283,6 +340,7 @@ impl BlockEntity {
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -298,7 +356,8 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => Vec::new(),
| Self::CommandBlock(_)
| Self::Skull(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -315,7 +374,8 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_) => &[],
| Self::Banner(_)
| Self::Skull(_) => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -335,7 +395,8 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_) => &mut [],
| Self::Banner(_)
| Self::Skull(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
+174
View File
@@ -0,0 +1,174 @@
//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
//! `use_cooldown` component's entries by cooldown group, counted down once a
//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
//! observers for the parity harness; this is the same data with the lookup a
//! slot needs, and no observer (vanilla's own player uses the plain class).
use crate::items::ItemStack;
use horizon_world::nbt::Tag;
use std::collections::HashMap;
use std::sync::Arc;
/// A stack's `minecraft:use_cooldown` component.
#[derive(Clone, Debug, PartialEq)]
pub struct UseCooldown {
pub seconds: f32,
/// `cooldown_group`: the group the entry is filed under, else the item.
pub group: Option<Arc<str>>,
}
impl UseCooldown {
/// `UseCooldown.ticks`: the seconds in ticks, truncated.
pub fn ticks(&self) -> i32 {
(self.seconds * 20.0) as i32
}
/// The component as `items.json` holds it.
pub fn from_json(v: &serde_json::Value) -> Option<Self> {
let seconds = v.get("seconds")?.as_f64()? as f32;
Some(Self {
seconds,
group: v
.get("cooldown_group")
.and_then(|g| g.as_str())
.map(Arc::from),
})
}
/// The component as a stack's saved `components` holds it.
pub fn from_tag(t: &Tag) -> Option<Self> {
let Tag::Compound(c) = t else {
return None;
};
let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
Some(Tag::Float(bits)) => f32::from_bits(*bits),
Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
_ => return None,
};
Some(Self {
seconds,
group: text("cooldown_group").map(Arc::from),
})
}
}
/// The component a stack carries: its own patch first (`ItemStack.get`), then
/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option<UseCooldown> {
stack
.data()
.and_then(|d| d.other("minecraft:use_cooldown"))
.and_then(UseCooldown::from_tag)
.or_else(|| default.cloned())
}
/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc<str> {
component(stack, default)
.and_then(|c| c.group)
.unwrap_or_else(|| Arc::clone(&stack.item))
}
/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
#[derive(Clone, Debug, Default)]
pub struct Cooldowns {
tick_count: i32,
entries: HashMap<Arc<str>, (i32, i32)>,
}
impl Cooldowns {
/// `getCooldownPercent`: what is left of the entry, from 1 to 0.
pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
let Some((start, end)) = self.entries.get(group) else {
return 0.0;
};
let duration = end.wrapping_sub(*start) as f32;
let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
(remaining / duration).clamp(0.0, 1.0)
}
/// `isOnCooldown`: whether the group still has time to run.
pub fn is_on_cooldown(&self, group: &str) -> bool {
self.percent(group, 0.0) > 0.0
}
/// `addCooldown`.
pub fn add(&mut self, group: Arc<str>, ticks: i32) {
self.entries.insert(
group,
(self.tick_count, self.tick_count.wrapping_add(ticks)),
);
}
/// `ItemCooldowns.tick`: the counter, then every entry that has run out,
/// which it hands back (`onCooldownEnded`).
pub fn tick(&mut self) -> Vec<Arc<str>> {
self.tick_count = self.tick_count.wrapping_add(1);
let done: Vec<Arc<str>> = self
.entries
.iter()
.filter(|(_, (_, end))| *end <= self.tick_count)
.map(|(group, _)| Arc::clone(group))
.collect();
self.entries.retain(|_, (_, end)| *end > self.tick_count);
done
}
/// `removeCooldown`.
pub fn remove(&mut self, group: &str) {
self.entries.remove(group);
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn stack(item: &str) -> ItemStack {
ItemStack::new(item, 1)
}
#[test]
fn the_percent_falls_from_one_to_zero_over_the_entry() {
let mut c = Cooldowns::default();
c.add(Arc::from("minecraft:ender_pearl"), 20);
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
for _ in 0..10 {
assert!(c.tick().is_empty());
}
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
// The partial tick moves it within the tick.
assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
for _ in 0..9 {
c.tick();
}
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
// ItemCooldowns.tick drops the entry at its end tick, and says so.
assert_eq!(c.tick(), [Arc::<str>::from("minecraft:ender_pearl")]);
assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
assert!(c.is_empty());
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
}
#[test]
fn the_group_comes_from_the_stack_then_the_item() {
let pearl = stack("minecraft:ender_pearl");
let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
// A stack's own component overrides the item's, group included.
let mut grouped = stack("minecraft:ender_pearl");
grouped.data_mut().set_other(
"minecraft:use_cooldown",
Some(Tag::compound([
("seconds", Tag::Float(2.0f32.to_bits())),
("cooldown_group", Tag::String("minecraft:pearls".into())),
])),
);
let c = component(&grouped, Some(&default)).expect("the stack's component");
assert_eq!(c.ticks(), 40);
assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
// An item without the component has no cooldown.
assert!(component(&stack("minecraft:dirt"), None).is_none());
}
}
@@ -84,6 +84,33 @@ impl Displayer {
}
}
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
/// pass's median milliseconds, in the order they run).
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
const SHOWN: usize = 8;
if passes.is_empty() {
return Vec::new();
}
let total: f32 = passes.iter().map(|p| p.1).sum();
let mut lines = vec![format!(
"Shader pack GPU: {total:.2} ms in {} passes",
passes.len()
)];
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
lines.extend(
costliest
.iter()
.take(SHOWN)
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
);
if costliest.len() > SHOWN {
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
}
lines
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -337,4 +364,15 @@ mod tests {
(1.0, [640, 360])
);
}
#[test]
fn shader_pass_lines_list_the_costliest_passes_first() {
assert!(shader_pass_lines(&[]).is_empty());
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
let lines = shader_pass_lines(&passes);
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
assert_eq!(lines[1], "p9: 9.00 ms");
assert_eq!(lines[8], "p2: 2.00 ms");
assert_eq!(lines[9], "2 more: 1.00 ms");
}
}
+794
View File
@@ -0,0 +1,794 @@
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
//! click event an action makes of them (`Action.createAction`).
use crate::text::Text;
use horizon_world::nbt::Tag;
use serde_json::{Value, json};
/// `CommonDialogData` and the type's own fields.
#[derive(Clone, Debug)]
pub struct Dialog {
pub kind: Kind,
pub title: Text,
pub external_title: Option<Text>,
pub can_close_with_escape: bool,
pub pause: bool,
pub after_action: AfterAction,
pub body: Vec<Body>,
pub inputs: Vec<Input>,
}
/// The dialog types (`DialogTypes`).
#[derive(Clone, Debug)]
pub enum Kind {
/// `NoticeDialog`: one button, "OK" when the server names none.
Notice(Button),
Confirmation {
yes: Button,
no: Button,
},
MultiAction {
actions: Vec<Button>,
exit: Option<Button>,
columns: i32,
},
ServerLinks {
exit: Option<Button>,
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
DialogList {
dialogs: Tag,
exit: Option<Button>,
columns: i32,
button_width: i32,
},
}
/// `DialogAction`: the screen after an action runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AfterAction {
Close,
None,
WaitForResponse,
}
/// `ActionButton`: `CommonButtonData` and what it does.
#[derive(Clone, Debug)]
pub struct Button {
pub label: Text,
pub tooltip: Option<Text>,
pub width: i32,
pub action: Option<Action>,
}
/// `Action`: a fixed click event, or one made from the inputs' values.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
/// `StaticAction`: a click event as it is.
Static(Click),
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
RunCommand(String),
/// `CustomAll`: a custom click event whose payload is `additions` plus
/// every input's value under its key.
Custom { id: String, additions: Option<Tag> },
}
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
OpenUrl(String),
RunCommand(String),
SuggestCommand(String),
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
ShowDialog(Tag),
ChangePage(i32),
CopyToClipboard(String),
Custom {
id: String,
payload: Option<Tag>,
},
}
/// `DialogBody`.
#[derive(Clone, Debug)]
pub enum Body {
/// `PlainMessage`: centred text wrapped to `width`.
Message { contents: Text, width: i32 },
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
Item {
item: Tag,
description: Option<(Text, i32)>,
show_decorations: bool,
show_tooltip: bool,
width: i32,
height: i32,
},
}
/// `Input`: the key its value goes by, and its control.
#[derive(Clone, Debug)]
pub struct Input {
pub key: String,
pub control: Control,
}
/// `InputControl`.
#[derive(Clone, Debug)]
pub enum Control {
Text {
width: i32,
label: Text,
label_visible: bool,
initial: String,
max_length: i32,
/// `MultilineOptions`: the most lines and the height, when set.
multiline: Option<(Option<i32>, Option<i32>)>,
},
Boolean {
label: Text,
initial: bool,
on_true: String,
on_false: String,
},
SingleOption {
width: i32,
/// Each option's id, its label and whether it is the initial one.
options: Vec<(String, Option<Text>, bool)>,
label: Text,
label_visible: bool,
},
NumberRange {
width: i32,
label: Text,
label_format: String,
start: f32,
end: f32,
initial: Option<f32>,
step: Option<f32>,
},
}
/// What an input holds while the dialog is open.
#[derive(Clone, Debug, PartialEq)]
pub enum InputValue {
Text(String),
Bool(bool),
/// The chosen option's index.
Option(usize),
/// The slider's position, 0 to 1.
Slider(f32),
}
fn text(t: &Tag) -> Text {
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
}
fn string(t: Option<&Tag>) -> Option<String> {
match t? {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
}
}
fn number(t: Option<&Tag>) -> Option<f64> {
Some(match t? {
Tag::Byte(v) => f64::from(*v),
Tag::Short(v) => f64::from(*v),
Tag::Int(v) => f64::from(*v),
Tag::Long(v) => *v as f64,
Tag::Float(v) => f64::from(f32::from_bits(*v)),
Tag::Double(v) => f64::from_bits(*v),
_ => return None,
})
}
fn int(t: &Tag, key: &str, default: i32) -> i32 {
number(t.get(key)).map_or(default, |v| v as i32)
}
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
number(t.get(key)).map_or(default, |v| v != 0.0)
}
fn list(t: Option<&Tag>) -> &[Tag] {
match t {
Some(Tag::List(items)) => items,
_ => &[],
}
}
/// A registry name without its default namespace.
fn path(id: &str) -> &str {
id.strip_prefix("minecraft:").unwrap_or(id)
}
impl Button {
/// `ActionButton.CODEC`.
fn parse(t: &Tag) -> Option<Self> {
Some(Self {
label: text(t.get("label")?),
tooltip: t.get("tooltip").map(text),
width: int(t, "width", 150),
action: t.get("action").and_then(Action::parse),
})
}
/// `NoticeDialog.DEFAULT_ACTION`.
fn ok() -> Self {
Self {
label: Text::translate("gui.ok", &[]),
tooltip: None,
width: 150,
action: None,
}
}
}
impl Click {
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
/// or `type` (an action) field.
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
let s = |key| string(t.get(key));
Some(match path(kind) {
"open_url" => Self::OpenUrl(s("url")?),
"run_command" => Self::RunCommand(s("command")?),
"suggest_command" => Self::SuggestCommand(s("command")?),
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
"change_page" => Self::ChangePage(int(t, "page", 1)),
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
"custom" => Self::Custom {
id: s("id")?,
payload: t.get("payload").cloned(),
},
_ => return None,
})
}
}
impl Action {
/// `Action.CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let kind = string(t.get("type"))?;
Some(match path(&kind) {
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
"dynamic/custom" => Self::Custom {
id: string(t.get("id"))?,
additions: t.get("additions").cloned(),
},
other => Self::Static(Click::parse(t, other)?),
})
}
/// `createAction`: the click event, given each input's key, control and value.
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
let values = || inputs.iter().zip(values);
Some(match self {
Self::Static(click) => click.clone(),
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
values()
.find(|(i, _)| i.key == key)
.map(|(i, v)| i.control.substitution(v))
})),
Self::Custom { id, additions } => {
let mut tag = match additions {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
for (input, value) in values() {
if let Tag::Compound(map) = &mut tag {
map.insert(input.key.as_str().into(), input.control.tag(value));
}
}
Click::Custom {
id: id.clone(),
payload: Some(tag),
}
}
})
}
}
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
/// nothing for a key no input has.
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(at) = rest.find("$(") {
let Some(end) = rest[at..].find(')') else {
break;
};
out.push_str(&rest[..at]);
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
rest = &rest[at + end + 1..];
}
out.push_str(rest);
out
}
/// `StringTag.escapeWithoutQuotes`.
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' | '\'' | '\\' => {
out.push('\\');
out.push(c);
}
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out
}
impl Control {
/// `InputControl.MAP_CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
.iter()
.filter_map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
o.get("display").map(text),
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
width: int(t, "width", 200),
label: label()?,
label_format: string(t.get("label_format"))
.unwrap_or_else(|| "options.generic_value".into()),
start: float("start")?,
end: float("end")?,
initial: float("initial"),
step: float("step"),
}
}
_ => return None,
})
}
/// The value it starts with.
pub fn initial(&self) -> InputValue {
match self {
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
Self::SingleOption { options, .. } => {
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
}
Self::NumberRange {
start,
end,
initial,
..
} => InputValue::Slider(to_slider(
initial.unwrap_or((start + end) / 2.0),
*start,
*end,
)),
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
(
Self::Boolean {
on_true, on_false, ..
},
InputValue::Bool(b),
) => if *b { on_true } else { on_false }.clone(),
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
}
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
number_string(self.scaled(value).unwrap_or(0.0))
}
_ => String::new(),
}
}
/// `ValueGetter.asTag`.
pub fn tag(&self, value: &InputValue) -> Tag {
match (self, value) {
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
(Self::NumberRange { .. }, _) => {
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
}
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
_ => Tag::String(self.substitution(value).as_str().into()),
}
}
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
let (
Self::NumberRange {
start,
end,
initial,
step,
..
},
InputValue::Slider(slider),
) = (self, value)
else {
return None;
};
let (start, end) = (*start, *end);
let in_range = start + slider * (end - start);
let Some(step) = step else {
return Some(in_range);
};
let first = initial.unwrap_or((start + end) / 2.0);
// Math.round: half up.
let steps = ((in_range - first) / step + 0.5).floor() as i32;
let result = first + steps as f32 * step;
let position = to_slider(result, start, end);
if (0.0..=1.0).contains(&position) {
return Some(result);
}
Some(first + (steps - steps.signum()) as f32 * step)
}
}
/// `RangeInfo.scaledValueToSlider`.
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
if start == end {
0.5
} else {
(value - start) / (end - start)
}
}
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
/// Java's `Float.toString`.
pub fn number_string(v: f32) -> String {
if v == (v as i32) as f32 {
(v as i32).to_string()
} else {
crate::snbt::java_float(v)
}
}
impl Body {
/// `DialogBody.DIALOG_BODY_CODEC`.
fn parse(t: &Tag) -> Option<Self> {
let message = |t: &Tag| -> Option<(Text, i32)> {
// PlainMessage.CODEC: the full form or just its contents.
match t {
Tag::Compound(_) if t.get("contents").is_some() => {
Some((text(t.get("contents")?), int(t, "width", 200)))
}
t => Some((text(t), 200)),
}
};
Some(match path(&string(t.get("type"))?) {
"plain_message" => {
let (contents, width) = message(t)?;
Self::Message { contents, width }
}
"item" => Self::Item {
item: t.get("item")?.clone(),
description: t.get("description").and_then(message),
show_decorations: boolean(t, "show_decorations", true),
show_tooltip: boolean(t, "show_tooltip", true),
width: int(t, "width", 16),
height: int(t, "height", 16),
},
_ => return None,
})
}
}
impl Dialog {
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
/// (vanilla fails the whole packet then).
pub fn parse(t: &Tag) -> Option<Self> {
let button = |key: &str| t.get(key).and_then(Button::parse);
let kind = match path(&string(t.get("type"))?) {
"notice" => Kind::Notice(match t.get("action") {
Some(b) => Button::parse(b)?,
None => Button::ok(),
}),
"confirmation" => Kind::Confirmation {
yes: button("yes")?,
no: button("no")?,
},
"multi_action" => Kind::MultiAction {
actions: list(t.get("actions"))
.iter()
.map(Button::parse)
.collect::<Option<Vec<_>>>()
.filter(|a| !a.is_empty())?,
exit: button("exit_action"),
columns: int(t, "columns", 2),
},
"server_links" => Kind::ServerLinks {
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
Some(Self {
kind,
title: text(t.get("title")?),
external_title: t.get("external_title").map(text),
can_close_with_escape: boolean(t, "can_close_with_escape", true),
pause: boolean(t, "pause", true),
after_action: match string(t.get("after_action")).as_deref() {
Some("none") => AfterAction::None,
Some("wait_for_response") => AfterAction::WaitForResponse,
_ => AfterAction::Close,
},
body,
inputs,
})
}
/// `onCancel`: what Escape (or closing it) does.
pub fn on_cancel(&self) -> Option<&Action> {
match &self.kind {
Kind::Notice(b) => b.action.as_ref(),
Kind::Confirmation { no, .. } => no.action.as_ref(),
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
}
}
/// What `status` reports of it.
pub fn json(&self, values: &[InputValue]) -> Value {
let kind = match &self.kind {
Kind::Notice(_) => "notice",
Kind::Confirmation { .. } => "confirmation",
Kind::MultiAction { .. } => "multi_action",
Kind::ServerLinks { .. } => "server_links",
Kind::DialogList { .. } => "dialog_list",
};
let inputs: Vec<Value> = self
.inputs
.iter()
.zip(values)
.map(|(i, v)| {
let kind = match i.control {
Control::Text { .. } => "text",
Control::Boolean { .. } => "boolean",
Control::SingleOption { .. } => "single_option",
Control::NumberRange { .. } => "number_range",
};
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
})
.collect();
json!({
"type": kind,
"title": self.title.plain(),
"body": self.body.iter().map(|b| match b {
Body::Message { contents, .. } => contents.plain(),
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
}).collect::<Vec<_>>(),
"inputs": inputs,
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
"can_close_with_escape": self.can_close_with_escape,
"after_action": match self.after_action {
AfterAction::Close => "close",
AfterAction::None => "none",
AfterAction::WaitForResponse => "wait_for_response",
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &str) -> Tag {
Tag::String(v.into())
}
/// The register dialog AuthMe 6 sends in configuration (the shape its
/// `show_dialog` packet has on the test bed).
fn register() -> Tag {
Tag::compound([
("type", s("minecraft:multi_action")),
("title", s("Register")),
("can_close_with_escape", Tag::Byte(0)),
("after_action", s("wait_for_response")),
(
"body",
Tag::compound([
("type", s("minecraft:plain_message")),
("contents", s("Choose a password")),
]),
),
(
"inputs",
Tag::List(vec![Tag::compound([
("type", s("minecraft:text")),
("key", s("password")),
("label", s("Password")),
])]),
),
(
"actions",
Tag::List(vec![
Tag::compound([
("label", s("Register")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/custom")),
("id", s("authme:prejoin-register/submit")),
]),
),
]),
Tag::compound([
("label", s("Cancel")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/run_command")),
("template", s("say $(password) $(missing)!")),
]),
),
]),
]),
),
])
}
#[test]
fn decodes_a_multi_action_dialog_with_defaults() {
let d = Dialog::parse(&register()).unwrap();
assert_eq!(d.title.plain(), "Register");
assert!(!d.can_close_with_escape);
assert!(d.pause);
assert_eq!(d.after_action, AfterAction::WaitForResponse);
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
let Kind::MultiAction {
actions,
exit,
columns,
} = &d.kind
else {
panic!("{:?}", d.kind)
};
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
assert_eq!(actions[0].width, 150);
assert!(d.on_cancel().is_none());
let [
Input {
key,
control:
Control::Text {
max_length: 32,
width: 200,
..
},
},
] = &d.inputs[..]
else {
panic!("{:?}", d.inputs)
};
assert_eq!(key, "password");
}
#[test]
fn actions_carry_the_input_values() {
let d = Dialog::parse(&register()).unwrap();
let values = [InputValue::Text("p\"w".into())];
let Kind::MultiAction { actions, .. } = &d.kind else {
unreachable!()
};
let custom = actions[0]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(
custom,
Some(Click::Custom {
id: "authme:prejoin-register/submit".into(),
payload: Some(Tag::compound([("password", s("p\"w"))])),
})
);
// The template escapes the text; a key no input has becomes nothing.
let command = actions[1]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
let Kind::Notice(b) = &d.kind else {
unreachable!()
};
assert_eq!(b.label, Text::translate("gui.ok", &[]));
// Without a title, or with an unknown type, there is no dialog.
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
}
#[test]
fn number_ranges_snap_to_their_step() {
let mut range = Control::NumberRange {
width: 200,
label: Text::literal("n"),
label_format: "options.generic_value".into(),
start: 0.0,
end: 10.0,
initial: Some(4.0),
step: Some(3.0),
};
assert_eq!(range.initial(), InputValue::Slider(0.4));
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
let Control::NumberRange { step, .. } = &mut range else {
unreachable!()
};
*step = Some(4.0);
// 12 is out of range: one step less.
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
assert_eq!(number_string(2.5), "2.5");
}
}
+382
View File
@@ -0,0 +1,382 @@
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
//! where a server dialog's title, body, inputs and buttons go in a
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
use crate::{
dialog::{Body, Control, Dialog, Kind},
font::Font,
text::Text,
};
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
const BAR: f32 = 33.0;
/// `Checkbox.getBoxSize`.
pub const BOX: f32 = 17.0;
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
pub const WARNING_SPRITES: [&str; 3] = [
"gui/sprites/dialog/warning_button",
"gui/sprites/dialog/warning_button_disabled",
"gui/sprites/dialog/warning_button_highlighted",
];
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
/// the body's parts, from the top of the screen for the footer's.
#[derive(Clone, Debug, PartialEq)]
pub enum Part {
/// A line of text and where it starts.
Line(Text, f32, f32),
/// A text input's box.
Field { input: usize, rect: [f32; 4] },
/// A boolean input: the box, its label right of it.
Check { input: usize, rect: [f32; 4] },
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
}
#[derive(Clone, Debug)]
pub struct Layout {
/// The title and where it is drawn.
pub title: (f32, f32),
pub warning: [f32; 4],
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
pub area: [f32; 4],
/// The body's full height: more than the area's scrolls.
pub content: f32,
pub body: Vec<Part>,
pub footer: Vec<Part>,
}
/// A laid-out element of a `LinearLayout` before it is placed.
struct Element {
w: f32,
h: f32,
parts: Vec<Part>,
}
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, x, y),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, x, y),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
[x + dx, y + dy, w, h]
}
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
fn centre(outer: f32, inner: f32) -> f32 {
((outer - inner) * 0.5).trunc()
}
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
/// wrapped to `width - 8` and centred, 4 of padding all round.
fn message(font: &Font, contents: &Text, width: f32) -> Element {
let lines = font.split_lines(contents, width - 8.0);
let mid = (width / 2.0).trunc();
let parts = lines
.into_iter()
.enumerate()
.map(|(i, (line, _))| {
let x = mid - (font.width_text(&line) / 2.0).trunc();
Part::Line(line, x, 4.0 + 9.0 * i as f32)
})
.collect::<Vec<_>>();
Element {
w: width,
h: 9.0 * parts.len() as f32 + 8.0,
parts,
}
}
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
Some(match control {
Control::Text {
width,
label,
label_visible,
..
} => {
let w = *width as f32;
if !label_visible {
return Some(Element {
w,
h: 20.0,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
}],
});
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
Element {
w: w.max(font.width_text(label)),
h: 9.0 + 4.0 + 20.0,
parts: vec![
Part::Line(label.clone(), 0.0, 0.0),
Part::Field {
input: i,
rect: [0.0, 13.0, w, 20.0],
},
],
}
}
Control::Boolean { label, .. } => {
let w = BOX + 4.0 + font.width_text(label);
Element {
w,
h: BOX,
parts: vec![Part::Check {
input: i,
rect: [0.0, 0.0, w, BOX],
}],
}
}
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
})
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
/// is left in a centred row under them.
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
let columns = columns.max(1);
let full = widths.len() / columns * columns;
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
let mut col = vec![0.0f32; columns];
for (i, w) in widths[..full].iter().enumerate() {
col[i % columns] = col[i % columns].max(*w);
}
let last = &widths[full..];
if !last.is_empty() {
// GridLayout spreads a cell over every column it spans (Divisor).
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
for (c, w) in col.iter_mut().enumerate() {
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
}
}
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
let mut parts = Vec::new();
for (i, w) in widths[..full].iter().enumerate() {
let (r, c) = (i / columns, i % columns);
parts.push(Part::Button {
index: first + i,
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
});
}
let width = x_of(columns - 1) + col[columns - 1];
if !last.is_empty() {
let y = 22.0 * (full / columns) as f32;
let mut x = centre(width, row_width(last));
for (i, w) in last.iter().enumerate() {
parts.push(Part::Button {
index: first + full + i,
rect: [x, y, *w, 20.0],
});
x += w + 2.0;
}
}
let rows = full / columns + usize::from(!last.is_empty());
Element {
w: width,
h: (22.0 * rows as f32 - 2.0).max(0.0),
parts,
}
}
impl Layout {
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
// The header: the title, 10 right of it the warning button, both
// centred together.
let tw = font.width_text(&dialog.title);
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
let title = (hx, hy + centre(20.0, 9.0));
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
})
.collect();
elements.extend(
dialog
.inputs
.iter()
.enumerate()
.filter_map(|(i, input_)| input(font, i, &input_.control)),
);
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
// SimpleDialogScreen: the buttons side by side, 8 apart.
Kind::Notice(_) | Kind::Confirmation { .. } => {
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
}
};
debug_assert!(listed + footer_buttons.len() <= buttons.len());
// The body: a column 10 apart, each element centred in it.
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
let content = elements.iter().map(|e| e.h).sum::<f32>()
+ 10.0 * elements.len().saturating_sub(1) as f32;
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
// no higher than what the header and footer leave.
let area_w = body_w + 20.0;
let area_h = content.min(h - BAR - footer_h).max(0.0);
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
let area_x = centre(w, area_w);
let mut body = Vec::new();
let mut y = 0.0;
for e in elements {
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
body.extend(e.at(x, y));
y += eh + 10.0;
}
let widths: Vec<f32> = footer_buttons
.iter()
.map(|&i| buttons[i].width as f32)
.collect();
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
let mut footer = Vec::new();
for (&i, bw) in footer_buttons.iter().zip(&widths) {
footer.push(Part::Button {
index: i,
rect: [x, fy, *bw, 20.0],
});
x += bw + 8.0;
}
Self {
title,
warning: Self::in_bounds(warning, w, h),
area: [area_x, area_y, area_w, area_h],
content,
body,
footer,
}
}
/// `makeSureWarningButtonIsInBounds`.
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
} else {
r
}
}
/// Every part where it is drawn with the body scrolled by `scroll`, and
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, 0.0, dy),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, 0.0, dy),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
/// whether it shows and works (it shows after a second, works after five).
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
let seconds = (ticks / 20).min(5);
let label = match seconds {
0 => Text::literal(""),
5 => Text::translate("gui.back", &[]),
s => Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal((5 - s).to_string())],
),
};
(label, seconds >= 1, seconds == 5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
let two = columns(&[150.0, 150.0], 2, 0);
assert_eq!((two.w, two.h), (302.0, 20.0));
// Three in two columns: the third alone, centred under them.
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
assert_eq!((three.w, three.h), (302.0, 42.0));
assert_eq!(
three.parts[2],
Part::Button {
index: 2,
rect: [76.0, 22.0, 150.0, 20.0]
}
);
// One in two columns spreads over both.
let one = columns(&[100.0], 2, 0);
assert_eq!(one.w, 100.0);
}
#[test]
fn the_waiting_screens_button_counts_down() {
assert!(!waiting_button(0).1);
assert_eq!(
waiting_button(20).0,
Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal("4")]
)
);
assert_eq!(
waiting_button(100),
(Text::translate("gui.back", &[]), true, true)
);
}
}
+50 -5
View File
@@ -33,7 +33,11 @@ impl Cost {
#[derive(Clone, Debug)]
pub struct Enchantment {
pub id: Arc<str>,
/// The name the pinned English map gave at load: the fallback for a
/// language that has no entry for the key below.
pub description: String,
/// `description`'s translation key (`enchantment.minecraft.<name>`).
pub description_key: String,
pub max_level: u32,
pub weight: u32,
pub anvil_cost: u32,
@@ -224,6 +228,7 @@ impl Enchantments {
.get(key)
.cloned()
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
description_key: key.to_owned(),
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
weight: v["weight"].as_u64().unwrap_or(1) as u32,
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
@@ -329,16 +334,26 @@ impl Enchantments {
}
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
let language = crate::lang::current();
self.full_name_in(language.as_deref(), id, level)
}
/// `full_name` against `language`; `None` (no language loaded: a
/// dedicated server, the probes) keeps the English names loaded above.
pub fn full_name_in(
&self,
language: Option<&crate::lang::Language>,
id: &str,
level: u32,
) -> (String, bool) {
let tr = |key: &str| language.and_then(|l| l.get(key).map(str::to_owned));
let Some(e) = self.get(id) else {
return (format!("{id} {}", level_name(level)), false);
};
let mut name = e.description.clone();
let mut name = tr(&e.description_key).unwrap_or_else(|| e.description.clone());
if level != 1 || e.max_level != 1 {
name.push(' ');
name.push_str(
self.lang
.get(&format!("enchantment.level.{level}"))
.map_or(&level_name(level), |s| s),
&tr(&format!("enchantment.level.{level}")).unwrap_or_else(|| level_name(level)),
);
}
(name, self.is_curse(id))
@@ -771,7 +786,7 @@ pub(crate) mod tests {
let defs = [
(
"minecraft:sharpness",
json!({"anvil_cost":1,"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
json!({"anvil_cost":1,"description":{"translate":"enchantment.minecraft.sharpness"},"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
),
(
"minecraft:smite",
@@ -795,6 +810,7 @@ pub(crate) mod tests {
.map(|(id, v)| Enchantment {
id: Arc::from(*id),
description: id.trim_start_matches("minecraft:").into(),
description_key: v["description"]["translate"].as_str().unwrap_or("").into(),
max_level: v["max_level"].as_u64().unwrap() as u32,
weight: v["weight"].as_u64().unwrap() as u32,
anvil_cost: v["anvil_cost"].as_u64().unwrap() as u32,
@@ -1088,4 +1104,33 @@ pub(crate) mod tests {
assert!(!e.compatible("minecraft:sharpness", "minecraft:smite"));
assert!(e.compatible("minecraft:sharpness", "minecraft:unbreaking"));
}
/// The enchantment's name and its level come from the selected language,
/// with the English name loaded above as the fallback.
#[test]
fn names_follow_the_language() {
struct Mem(HashMap<String, Vec<u8>>);
impl crate::lang::ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
let ru = Mem(HashMap::from([(
"assets/minecraft/lang/ru_ru.json".to_owned(),
r#"{"enchantment.minecraft.sharpness":"Острота","enchantment.level.5":"V"}"#
.as_bytes()
.to_vec(),
)]));
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
let e = sample();
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
"Острота V"
);
// mending carries no key in the fixture: the English name stays.
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:mending", 1).0,
"mending"
);
}
}
+15 -5
View File
@@ -25,6 +25,20 @@ impl Culler {
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
let hidden = self.hidden(vertices, start, opaque);
if hidden {
vertices.truncate(start);
}
hidden
}
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
pub fn hidden(
&self,
vertices: &[crate::mesher::Vertex],
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if start >= vertices.len() || self.remaining.get() == 0 {
return false;
@@ -42,11 +56,7 @@ impl Culler {
}
}
// Cover floating-point and rasterization edges as well as the vertices.
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
vertices.truncate(start);
return true;
}
false
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
}
/// Returns true only when a complete opaque block plane covers the bounds.
+34 -267
View File
@@ -1,4 +1,4 @@
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
//! Horizon's optional extras (minimap, lighting, inventory tools): their
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
@@ -6,17 +6,8 @@
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the zoom key waits for a key.
pub const ZOOM_KEY: &str = "horizon.zoom";
/// The same for the sort key.
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// Zoom levels the default cycles through, and the wheel's limits.
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
pub const ZOOM_MIN: f32 = 1.25;
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
pub const ZOOM_MAX: f32 = 10.0;
/// Each wheel step multiplies the zoom by this.
const ZOOM_STEP: f32 = 1.25;
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -81,6 +72,9 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// The FPS counter and keystrokes extras are official mods now
/// (`moved_extras`): their old settings stay here until the game has
/// installed the mod for a player who had one on.
pub fps: bool,
pub fps_corner: Corner,
/// Also show the minimum and 1% low.
@@ -89,6 +83,9 @@ pub struct Extras {
pub keystrokes_corner: Corner,
/// Clicks per second under the mouse buttons.
pub keystrokes_cps: bool,
/// The official mods installed for the extras they replace, by mod id
/// (`movedToMods`): each is done once.
pub moved: Vec<String>,
pub minimap: bool,
pub minimap_corner: Corner,
/// Side in GUI pixels (`MINIMAP_SIZES`).
@@ -132,6 +129,7 @@ impl Default for Extras {
keystrokes: false,
keystrokes_corner: Corner::TopLeft,
keystrokes_cps: true,
moved: Vec::new(),
minimap: false,
minimap_corner: Corner::TopRight,
minimap_size: 96,
@@ -177,7 +175,7 @@ impl Extras {
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
if let Ok(v) = value.parse::<u8>() {
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
e.zoom_level = v.clamp(2, 10);
}
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
@@ -190,6 +188,14 @@ impl Extras {
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
}
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
"movedToMods" => {
e.moved = value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
"minimap" => e.minimap = flag(e.minimap),
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
"minimapSize" => {
@@ -219,7 +225,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -233,6 +239,7 @@ impl Extras {
bool_text(self.keystrokes),
self.keystrokes_corner.name(),
bool_text(self.keystrokes_cps),
self.moved.join(","),
bool_text(self.minimap),
self.minimap_corner.name(),
self.minimap_size,
@@ -254,11 +261,10 @@ impl Extras {
bool_text(self.discord_presence),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
ZOOM_KEY => self.zoom_key = value,
SORT_KEY => self.sort_key = value,
_ => return false,
}
@@ -275,87 +281,12 @@ impl Extras {
}
}
/// The zoom key's state: held or not, and the zoom the wheel has set.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Zoom {
held: bool,
factor: f32,
}
impl Default for Zoom {
fn default() -> Self {
Self {
held: false,
factor: 1.0,
}
}
}
impl Zoom {
/// The key went down: start at the default level.
pub fn press(&mut self, level: u8) {
self.held = true;
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
}
pub fn release(&mut self) {
self.held = false;
}
/// A press or release of the zoom key. Only a press while `playing` (no
/// screen or text field open) zooms; anywhere else the key belongs to
/// the screen, and the zoom lets go.
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
if !pressed || !playing {
self.release();
} else if !repeat {
self.press(level);
}
}
pub fn held(&self) -> bool {
self.held
}
/// Wheel steps while held (positive zooms in); false when not zooming.
pub fn scroll(&mut self, steps: f32) -> bool {
if !self.held {
return false;
}
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
true
}
/// The zoom in effect: 1 when the key is up or the spyglass is in use
/// (scoping keeps vanilla's spyglass view).
pub fn factor(&self, scoping: bool) -> f32 {
if self.held && !scoping {
self.factor
} else {
1.0
}
}
}
/// Vanilla key mappings bound to the zoom key. The key still reaches them
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
/// says so.
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
if e.zoom_key == "key.keyboard.unknown" {
return Vec::new();
}
crate::keybinds::MAPPINGS
.iter()
.map(|m| m.0)
.filter(|name| keys.get(name) == e.zoom_key)
.collect()
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
}
/// The mouse turning scale while zoomed.
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
if lower_sensitivity && zoom > 1.0 {
1.0 / f64::from(zoom)
} else {
1.0
}
}
/// Frame times over the last few seconds: frames per second, the minimum
/// and the 1% low, recomputed twice a second.
pub struct FpsCounter {
@@ -447,17 +378,6 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
Zoom,
ZoomKey,
ZoomLevel,
ZoomSmooth,
ZoomSensitivity,
Fps,
FpsCorner,
FpsLows,
Keystrokes,
KeystrokesCorner,
KeystrokesCps,
Minimap,
MinimapCorner,
MinimapSize,
@@ -482,12 +402,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
Ok(ZoomLevel),
Ok(ZoomSmooth),
Ok(ZoomSensitivity),
Err("Minimap"),
Ok(Minimap),
Ok(MinimapCorner),
@@ -495,14 +409,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(MinimapShape),
Ok(MinimapZoom),
Ok(MinimapRotate),
Err("FPS counter"),
Ok(Fps),
Ok(FpsCorner),
Ok(FpsLows),
Err("Keystrokes"),
Ok(Keystrokes),
Ok(KeystrokesCorner),
Ok(KeystrokesCps),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -532,13 +438,7 @@ impl Item {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
Item::ZoomSmooth => "Smooth zoom",
Item::ZoomSensitivity => "Slower turning",
Item::Fps => "FPS counter",
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
Item::MinimapCorner => "Corner",
Item::Minimap => "Minimap",
Item::MinimapSize => "Size",
Item::MinimapShape => "Shape",
@@ -549,9 +449,6 @@ impl Item {
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
@@ -561,14 +458,11 @@ impl Item {
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
FpsCorner | FpsLows => Fps,
KeystrokesCorner | KeystrokesCps => Keystrokes,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
})
@@ -580,15 +474,7 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
Item::ZoomSmooth => "Eases in and out like the spyglass.",
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
Item::Fps => "Frames per second in a corner of the screen.",
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
"Overlays in one corner stack."
}
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.",
@@ -597,7 +483,6 @@ impl Item {
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -610,8 +495,6 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
@@ -627,7 +510,7 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Fullbright => Some(Cost::None),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
@@ -638,13 +521,6 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
Item::Fps => e.fps,
Item::FpsLows => e.fps_lows,
Item::Keystrokes => e.keystrokes,
Item::KeystrokesCps => e.keystrokes_cps,
Item::Minimap => e.minimap,
Item::MinimapRotate => e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect,
@@ -660,11 +536,7 @@ impl Item {
/// The value of the other items, as their button shows it.
pub fn value(self, e: &Extras) -> String {
match self {
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::ZoomLevel => format!("{}x", e.zoom_level),
Item::FpsCorner => e.fps_corner.label().into(),
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
Item::MinimapCorner => e.minimap_corner.label().into(),
Item::MinimapSize => MINIMAP_SIZES
.iter()
@@ -679,23 +551,11 @@ impl Item {
},
}
}
/// A click (`back` for the right button). The zoom key binds instead.
/// A click (`back` for the right button). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
Item::Fps => e.fps = !e.fps,
Item::FpsLows => e.fps_lows = !e.fps_lows,
Item::Keystrokes => e.keystrokes = !e.keystrokes,
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
Item::KeystrokesCorner => {
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
}
Item::Minimap => e.minimap = !e.minimap,
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
@@ -712,13 +572,12 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::ZoomKey | Item::SortKey => {}
Item::SortKey => {}
}
}
/// The key name the options screen waits on for a key item.
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::ZoomKey => Some(ZOOM_KEY),
Item::SortKey => Some(SORT_KEY),
_ => None,
}
@@ -750,6 +609,7 @@ mod tests {
e.fps_corner = Corner::BottomRight;
e.keystrokes = true;
e.keystrokes_cps = false;
e.moved = vec!["fps_overlay".into(), "keystrokes".into()];
e.minimap = true;
e.minimap_size = 128;
e.minimap_round = true;
@@ -775,43 +635,7 @@ mod tests {
}
#[test]
fn default_zoom_key_is_free_in_vanilla() {
let key = Extras::default().zoom_key;
assert!(crate::keybinds::key_code(&key).is_some());
assert!(
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
"{key} is a vanilla binding"
);
}
#[test]
fn zoom_is_vanilla_unless_held_and_not_scoping() {
let mut z = Zoom::default();
assert_eq!(z.factor(false), 1.0);
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
z.press(4);
assert_eq!(z.factor(false), 4.0);
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
assert!(z.scroll(1.0));
assert!((z.factor(false) - 5.0).abs() < 1e-5);
for _ in 0..40 {
z.scroll(1.0);
}
assert_eq!(z.factor(false), ZOOM_MAX);
for _ in 0..80 {
z.scroll(-1.0);
}
assert_eq!(z.factor(false), ZOOM_MIN);
z.release();
assert_eq!(z.factor(false), 1.0);
z.press(2);
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
}
#[test]
fn zoom_target_eases_like_the_spyglass() {
fn a_mods_zoom_eases_like_the_spyglass() {
// Camera.tickFov halves the distance to the target each tick.
let mut camera = crate::camera::Camera::default();
camera.tick(None, 1.0);
@@ -835,55 +659,6 @@ mod tests {
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
}
#[test]
fn the_zoom_key_only_zooms_while_playing() {
let mut z = Zoom::default();
// In chat, a sign, a text field or any screen: no zoom.
z.key(true, false, false, 4);
assert!(!z.held());
z.key(true, false, true, 4);
assert!(z.held());
// A screen opening while held lets go (its key presses are the screen's).
z.key(true, true, false, 4);
assert!(!z.held());
// Key repeats do not restart a zoom the wheel changed.
z.key(true, false, true, 4);
z.scroll(1.0);
z.key(true, true, true, 4);
assert!((z.factor(false) - 5.0).abs() < 1e-5);
z.key(false, false, true, 4);
assert!(!z.held());
}
#[test]
fn zoom_key_conflicts_name_the_vanilla_mappings() {
let mut e = Extras::default();
let mut keys = crate::keybinds::KeyBinds::default();
assert!(zoom_key_conflicts(&e, &keys).is_empty());
e.zoom_key = "key.keyboard.c".into();
assert_eq!(
zoom_key_conflicts(&e, &keys),
[
"key.saveToolbarActivator",
"key.debug.crash",
"key.debug.copyLocation"
]
);
keys.set("key.drop", "key.keyboard.z");
e.zoom_key = "key.keyboard.z".into();
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
e.zoom_key = "key.keyboard.unknown".into();
keys.set("key.smoothCamera", "key.keyboard.unknown");
assert!(zoom_key_conflicts(&e, &keys).is_empty());
}
#[test]
fn sensitivity_scales_with_zoom() {
assert_eq!(turn_scale(1.0, true), 1.0);
assert_eq!(turn_scale(4.0, false), 1.0);
assert_eq!(turn_scale(4.0, true), 0.25);
}
#[test]
fn fps_counter_reports_rate_minimum_and_low() {
let mut c = FpsCounter::default();
@@ -937,16 +712,8 @@ mod tests {
#[test]
fn items_cycle_through_their_values() {
let mut e = Extras::default();
Item::ZoomLevel.cycle(&mut e, false);
assert_eq!(e.zoom_level, 6);
Item::ZoomLevel.cycle(&mut e, true);
Item::ZoomLevel.cycle(&mut e, true);
assert_eq!(e.zoom_level, 3);
Item::FpsCorner.cycle(&mut e, true);
assert_eq!(e.fps_corner, Corner::BottomRight);
Item::Zoom.cycle(&mut e, false);
assert_eq!(Item::Zoom.switch(&e), Some(true));
assert_eq!(Item::ZoomKey.value(&e), "Z");
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);
+3 -133
View File
@@ -1,7 +1,7 @@
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
//! HUD boxes in the corners: the minimap's place and mods' panels, small
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
use crate::{
extras::{Corner, FpsReading},
extras::Corner,
hud::{HudAtlas, HudBatch, Rect, rgba},
};
@@ -99,36 +99,6 @@ pub fn draw_minimap(
}
}
/// The FPS counter's text.
pub fn fps_text(r: FpsReading, lows: bool) -> String {
if lows {
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
} else {
format!("{} fps", r.fps)
}
}
pub fn draw_fps(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
reading: FpsReading,
lows: bool,
) {
let text = fps_text(reading, lows);
let w = HudBatch::text_width(atlas, &text) + 4.0;
let (x, y) = corners.place(corner, w, 12.0);
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
batch.text(
atlas,
&text,
x + 2.0,
y + 2.0,
rgba(255, 255, 255, 255),
true,
);
}
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
pub fn draw_lines(
batch: &mut HudBatch,
@@ -155,95 +125,6 @@ pub fn draw_lines(
}
}
/// What the keystroke overlay shows.
pub struct Keystrokes {
/// Forward, left, back, right: held, and the bound key's name.
pub moves: [(bool, String); 4],
pub jump: bool,
pub attack: bool,
pub use_: bool,
/// Clicks per second of the left and right buttons, when shown.
pub cps: Option<(usize, usize)>,
}
const KEY: f32 = 22.0;
const GAP: f32 = 2.0;
/// The overlay's size.
pub fn keystrokes_size() -> (f32, f32) {
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
}
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
let [x, y, w, h] = rect;
let (fill, ink) = if held {
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
} else {
(BACKGROUND, rgba(255, 255, 255, 255))
};
batch.fill(atlas, x, y, w, h, fill);
let total = lines.len() as f32 * 9.0 - 1.0;
for (i, line) in lines.iter().enumerate() {
let tw = HudBatch::text_width(atlas, line);
let tx = (x + (w - tw) / 2.0).round();
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
batch.text(atlas, line, tx, ty, ink, !held);
}
}
pub fn draw_keystrokes(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
k: &Keystrokes,
) {
let (w, h) = keystrokes_size();
let (x, y) = corners.place(corner, w, h);
let step = KEY + GAP;
let [forward, left, back, right] = &k.moves;
key_box(
batch,
atlas,
[x + step, y, KEY, KEY],
forward.0,
&[&forward.1],
);
for (i, key) in [left, back, right].into_iter().enumerate() {
key_box(
batch,
atlas,
[x + i as f32 * step, y + step, KEY, KEY],
key.0,
&[&key.1],
);
}
let mw = (w - GAP) / 2.0;
let my = y + 2.0 * step;
for (i, (held, label, cps)) in [
(k.attack, "LMB", k.cps.map(|c| c.0)),
(k.use_, "RMB", k.cps.map(|c| c.1)),
]
.into_iter()
.enumerate()
{
let rate = cps.map(|n| format!("{n} CPS"));
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
key_box(
batch,
atlas,
[x + i as f32 * (mw + GAP), my, mw, KEY],
held,
&lines,
);
}
// The space bar: a line in a short key.
let sy = my + step;
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
let ink = if k.jump {
rgba(20, 20, 20, 255)
} else {
rgba(255, 255, 255, 255)
};
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -259,15 +140,4 @@ mod tests {
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
}
#[test]
fn fps_text_with_and_without_lows() {
let r = FpsReading {
fps: 144,
min: 61,
low: 98,
};
assert_eq!(fps_text(r, false), "144 fps");
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
}
}
+295 -48
View File
@@ -37,6 +37,7 @@ mod debug_keys;
mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
mod discord;
mod dispense_fixture;
mod dispenser;
@@ -97,12 +98,16 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mod_input;
mod mod_render;
mod mods;
mod mount_menu;
mod mounts;
mod moved_extras;
mod moving_pistons;
mod music;
mod names;
mod native_shaders;
mod natural_spawning;
mod nautili;
mod net_harness;
@@ -349,10 +354,7 @@ impl Options {
dedicated: None,
benchmark: None,
dev_mods: Vec::new(),
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0"),
execution_role: execution_role::ExecutionRole::Client,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
@@ -361,7 +363,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -378,6 +380,8 @@ impl Options {
"--control" => o.control = Some(horizon_client::control::default_path()),
"--server-thread" => o.server_thread = true,
"--in-frame" | "--no-server-thread" => o.server_thread = false,
// Horizon's own commands (/pregen), off by default as on vanilla.
"--pregen" => horizon_client::command::vanilla::set_horizon_commands(true),
_ => {
let value = args
.next()
@@ -554,6 +558,8 @@ struct GameLevel<'w> {
/// Where the chunk tickets let blocks tick.
tickets: horizon_client::ticket_levels::Levels,
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
/// Players with each chest open (a trapped chest's signal).
lid_openers: &'w HashMap<(i32, i32, i32), i32>,
}
struct PlayerCollision {
y: f64,
@@ -689,6 +695,10 @@ impl LevelAccess for GameLevel<'_> {
_state: BlockStateId,
_direction: Direction,
) -> i32 {
// TrappedChestBlock.getSignal: the players with it open, at most 15.
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
return (*count).clamp(0, 15);
}
jukebox::jukebox_signals(self.world, self.items, pos).map_or(0, |(signal, _)| signal)
}
fn biome_name(&self, pos: BlockPos) -> Option<&str> {
@@ -1141,6 +1151,7 @@ struct Game<'a> {
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
dialogs: dialogs::DialogState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -1209,6 +1220,8 @@ struct Game<'a> {
left: bool,
right: bool,
cursor: (f32, f32),
/// The cursor icon the window shows (`widgets::take_cursor` each frame).
cursor_icon: horizon_client::widgets::Cursor,
captured: bool,
focused: bool,
/// The keyboard's modifier state (`KeyEvent.modifiers`).
@@ -1240,6 +1253,11 @@ struct Game<'a> {
luminance: Option<horizon_client::dynamic_light::Luminance>,
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
see_through_vertices: Vec<Vertex>,
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// The shader pack's entity and block entity ids, while one draws entities.
pack_ids: Option<shader_packs::PackIds>,
started: Instant,
previous: Instant,
/// FramerateLimitTracker.latestInputTime.
@@ -1326,6 +1344,10 @@ struct Game<'a> {
hand: Hand,
/// The shield's item cooldown after an axe disabled it (ticks).
shield_cooldown: u32,
/// `Player.getCooldowns()`: the `use_cooldown` component's entries, what
/// `itemDecorations` shades a slot by. A server's players have their own
/// (`GuestPlay::cooldowns`), swapped in for their turn.
cooldowns: horizon_client::cooldowns::Cooldowns,
/// The source of a command run through /execute (the player otherwise).
command_source: Option<commands::execute::Source>,
titles: titles::Titles,
@@ -1489,15 +1511,20 @@ struct Game<'a> {
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (zoom, FPS counter, keystrokes): `game/extras.rs`.
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
extras: extras::ExtrasState,
/// Horizon mods (`game/mods.rs`), none running unless turned on.
mods: mods::ModsState,
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
native_shaders: native_shaders::NativeShaders,
/// Meshed sections waiting for an upload budget (Horizon settings).
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
entity_culler: Option<horizon_client::entity_culling::Culler>,
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
/// build the player's model in first person); (false, false) without a shader pack.
pack_shadow_casters: (bool, bool),
/// A scripted flight from the control socket (`fly`).
flight: Option<control::Flight>,
/// The running agent action from the control socket (game/agent.rs).
@@ -1552,6 +1579,8 @@ struct Game<'a> {
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
lan_global_events: Vec<(i32, [i32; 3], i32)>,
/// `ContainerOpenersCounter`: how many players (the host and the guests)
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
@@ -1564,6 +1593,9 @@ struct Game<'a> {
chat_capture: Option<Vec<horizon_client::text::Text>>,
/// Packets for the guest acting now, sent once its action returns.
guest_packets: Vec<horizon_protocol::packets::play::Clientbound>,
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -2177,7 +2209,7 @@ impl Game<'_> {
self.player_drop(thrown);
}
/// `Player.drop`: the statistics and a thrown item entity in front of the player.
fn player_drop(&mut self, thrown: ItemStack) {
pub(super) fn player_drop(&mut self, thrown: ItemStack) {
let Some(eye) = self.eye_position() else {
return;
};
@@ -2448,6 +2480,7 @@ impl Game<'_> {
spill: &mut self.spill,
tickets: self.tickets.levels(),
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -2584,10 +2617,41 @@ impl Game<'_> {
}
}
}
// BigDripleafBlock.entityInside also asks whether the player stands on
// the ground and how high its feet are: the leaves its box's feet
// layer is inside (the host and the LAN guests).
let mut standing = Vec::new();
let feet = self
.player
.iter()
.filter(|_| !self.spectator())
.map(|p| {
(
[p.position.x, p.position.y, p.position.z],
p.ground.on_ground,
f64::from(p.dimensions.width) / 2.0,
)
})
.chain(
self.lan_guest_feet()
.into_iter()
.map(|(at, on_ground)| (at, on_ground, 0.3)),
);
for (at, on_ground, w) in feet {
let y = (at[1] + 1e-7).floor() as i32;
for x in (at[0] - w + 1e-7).floor() as i32..=(at[0] + w - 1e-7).floor() as i32 {
for z in (at[2] - w + 1e-7).floor() as i32..=(at[2] + w - 1e-7).floor() as i32 {
standing.push((BlockPos::new(x, y, z), on_ground, at[1]));
}
}
}
self.with_level(|b, level, ticks| {
for pos in inside {
b.entity_inside(level, ticks, pos);
}
for (pos, on_ground, feet_y) in standing {
b.entity_on_big_dripleaf(level, ticks, pos, on_ground, feet_y);
}
});
self.process_level_events()?;
self.tick_scheduled()?;
@@ -2765,7 +2829,13 @@ impl Game<'_> {
}
_ => 1.0,
};
self.audio.play(name, c, volume, pitch);
if matches!(id, 1029 | 1030) {
// Clients play the anvil sound from its level event;
// capturing it also sends a duplicate sound packet.
self.audio.play_unshared(name, c, volume, pitch);
} else {
self.audio.play(name, c, volume, pitch);
}
}
}
LevelEvent::Sound {
@@ -3403,6 +3473,25 @@ impl Game<'_> {
|| self.interact_mob()?
|| self.interact_knot())
}
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
/// `use_cooldown` component starts its entry (`UseCooldown.apply`). A
/// server's guest also gets `ClientboundCooldownPacket`
/// (`ServerItemCooldowns.onCooldownStarted`), queued for their turn's end.
pub(super) fn apply_use_cooldown(&mut self, stack: &ItemStack) {
let default = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.use_cooldown.clone());
let Some(component) = horizon_client::cooldowns::component(stack, default.as_ref()) else {
return;
};
let group = horizon_client::cooldowns::group(stack, default.as_ref());
self.cooldowns.add(Arc::clone(&group), component.ticks());
if self.lan_acting.is_some() {
self.guest_cooldowns.push((group, component.ticks()));
}
}
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
/// then `useItem`. Whether the click ends here (it acted or failed).
fn use_hand(&mut self, block_hit: Option<&Hit>) -> Result<bool> {
@@ -3487,6 +3576,17 @@ impl Game<'_> {
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return Ok(false);
};
// ServerPlayerGameMode.useItem and useItemOn: an item on cooldown is
// not used (PASS, so the other hand may still act).
if self
.cooldowns
.is_on_cooldown(&horizon_client::cooldowns::group(
&stack,
info.use_cooldown.as_ref(),
))
{
return Ok(false);
}
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
return Ok(true);
}
@@ -3813,6 +3913,8 @@ impl Game<'_> {
}
if ticks >= duration {
self.using = None;
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
} else {
self.using = Some((slot, ticks));
@@ -4310,6 +4412,7 @@ impl Game<'_> {
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
MotionEvent::SmashLanding {
state,
@@ -4415,6 +4518,7 @@ impl Game<'_> {
// Player.tick's ItemCooldowns.tick for the window's player (a
// server's players count theirs down in `tick_guests`).
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
self.cooldowns.tick();
horizon_client::spans::time("tick_using", || self.tick_using());
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
@@ -4692,6 +4796,24 @@ impl Game<'_> {
}
window.set_cursor_visible(!self.captured);
}
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
/// of a frame: the cursor the frame's widgets asked for, set on the window
/// only when it changes.
fn apply_cursor_icon(&mut self) {
use horizon_client::widgets::Cursor;
let wanted = horizon_client::widgets::take_cursor();
if wanted == self.cursor_icon {
return;
}
if let Some(window) = &self.window {
window.set_cursor(match wanted {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
});
}
self.cursor_icon = wanted;
}
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
fn capture_loaded_world(&mut self) {
// Loading may finish after focus was lost or another screen opened.
@@ -4797,17 +4919,17 @@ impl Game<'_> {
count_text: Option<String>,
) {
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
// moved to (x, y). Horizon tracks no item cooldowns to shade yet.
let max_damage = self
.res
.items()
.get(&stack.item)
.map_or(0, |info| info.max_damage as i32);
// moved to (x, y): the damage bar, the cooldown shade and the count.
let info = self.res.items().get(&stack.item);
let max_damage = info.map_or(0, |info| info.max_damage as i32);
let cooldown =
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
let partial = self.clock.fraction() as f32;
let decorations = hud_overlays::ItemDecorations {
count: stack.count,
count_text,
bar: hud_overlays::damage_bar(stack.damage as i32, max_damage),
cooldown: 0.0,
cooldown: self.cooldowns.percent(&cooldown, partial),
};
let mut draws = vec![hud_layout::GuiDraw::Push(vec![
hud_layout::PoseOp::Translate(x, y),
@@ -4824,11 +4946,14 @@ impl Game<'_> {
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
let mut batch = if (self.server_packs.prompt.is_some() || self.dialog_open())
&& self.player.is_none()
{
HudBatch::new(size)
} else {
self.hud_layers(size)
};
self.draw_dialog(&mut batch);
self.draw_pack_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
@@ -4927,6 +5052,7 @@ impl Game<'_> {
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.draw_boss_bars(&mut batch);
self.draw_extras(&mut batch);
self.draw_mod_shapes(&mut batch);
}
if show_hud {
// Hud.nextContextualInfoState: the jump bar, the experience bar or
@@ -5141,7 +5267,8 @@ impl Game<'_> {
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_),
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -5410,7 +5537,8 @@ impl Game<'_> {
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_),
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -6391,7 +6519,11 @@ impl Game<'_> {
use horizon_client::menu::Input;
let button = i32::from(right);
if self.remote.is_some() {
self.remote_throw(right, input);
if self.screen == Screen::Creative {
self.remote_creative_drop(right);
} else {
self.remote_throw(right, input);
}
return;
}
let before = self.carried.clone();
@@ -6748,8 +6880,10 @@ impl Game<'_> {
alpha: f64,
mut out: Vec<Vertex>,
) -> Vec<Vertex> {
self.entity_spans.borrow_mut().clear();
self.prepare_entity_culling(camera);
self.prepare_trims();
self.mark_entity(out.len(), "minecraft:entity_flame");
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
self.yaw + 180.0
} else {
@@ -6841,14 +6975,21 @@ impl Game<'_> {
}
}
// `ItemEntityRenderer`.
self.mark_entity(out.len(), "minecraft:item");
self.dropped_item_geometry(camera, alpha, &mut out);
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles.
// Mobs and their projectiles (a span each).
self.mob_geometry(camera, alpha, &mut out);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), self.camera_type.first_person());
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
self.mark_entity(out.len(), "minecraft:player");
self.guest_model_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
@@ -6856,6 +6997,7 @@ impl Game<'_> {
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
let darken = self.sky_darken();
@@ -6919,6 +7061,7 @@ impl Game<'_> {
_ => None,
},
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
};
// Block entities outside the view frustum are skipped (with a
@@ -6949,13 +7092,14 @@ impl Game<'_> {
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
});
if self.entity_culler.is_some() {
if self.entity_culler.is_some() || self.pack_ids.is_some() {
for block in visible {
let start = out.len();
self.mark_block_entity(start, block.1);
self.res
.block_entity_render()
.render(&scene, std::iter::once(block), &mut out);
if !off_screen(block.1) {
if !off_screen(block.1) && self.entity_culler.is_some() {
self.cull_entity_vertices(&mut out, start);
}
}
@@ -6964,6 +7108,7 @@ impl Game<'_> {
.block_entity_render()
.render(&scene, visible, &mut out);
}
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.res
@@ -6972,6 +7117,7 @@ impl Game<'_> {
}
}
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
self.mark_entity(out.len(), "minecraft:experience_orb");
if let Some(r) = self
.gui
.atlas
@@ -7035,6 +7181,7 @@ impl Game<'_> {
}
}
// Falling blocks use their baked model, including non-cubic shapes.
self.mark_entity(out.len(), "minecraft:falling_block");
for f in &self.falling {
let Some((_, tint)) = self.block_face_sprite(f.state) else {
continue;
@@ -7065,20 +7212,35 @@ impl Game<'_> {
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
}
}
self.mark_entity(out.len(), "");
self.moving_piston_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:tnt");
self.tnt_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:painting");
self.painting_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:item_frame");
self.frame_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:armor_stand");
self.armor_stand_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:oak_boat");
self.boat_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:minecart");
self.cart_geometry(camera, alpha, &mut out);
// Beams: `gbuffers_beaconbeam`, not a block entity's program (5.4 has no stage for them).
self.mark_stage(out.len(), "NONE");
self.beacon_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:fishing_bobber");
self.fishing_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:leash_knot");
self.leash_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:trident");
self.trident_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:firework_rocket");
self.rocket_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:splash_potion");
self.thrown_potion_geometry(camera, alpha, &mut out);
// Particles (ParticleEngine.extract): camera-facing quads.
self.mark_stage(out.len(), "PARTICLES");
let world = &self.world;
self.particles.render(
camera,
@@ -7089,6 +7251,7 @@ impl Game<'_> {
&mut out,
);
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- camera)
@@ -8401,6 +8564,8 @@ impl Game<'_> {
1.0,
1.0,
);
// ItemStack.useOn: a use that acted counts as one of the item.
self.stats.add("used", &stack.item, 1);
self.damage_held(damage);
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), Some(new));
Ok(true)
@@ -8423,6 +8588,35 @@ impl Game<'_> {
self.level_set(pos, id)?;
Ok(true)
}
/// `LayeredCauldronBlock.handleEntityOnFireInside` for a burning player
/// in the cauldron at `pos` (`mayInteract`: not a spectator): the fill
/// level drops (`lowerFillLevel`, a `block_change` with no source). A
/// server's cauldron is the server's.
pub(super) fn cauldron_extinguish(&mut self, pos: BlockPos) {
if self.remote.is_some() {
return;
}
let at = [pos.x, pos.y, pos.z];
let registry = self.pack.assets.registry;
let Some(old) = registry
.state(self.world.block(at[0], at[1], at[2]))
.filter(|s| {
matches!(
s.name.as_str(),
"minecraft:water_cauldron" | "minecraft:powder_snow_cauldron"
)
})
.cloned()
else {
return;
};
let Ok(new) = registry.resolve(&horizon_client::cauldron::extinguished(&old)) else {
return;
};
if self.level_set(at, new).is_ok() {
self.post_block_game_event("minecraft:block_change", at, None, Some(new));
}
}
fn damage_held(&mut self, amount: u32) {
if self.creative {
return;
@@ -8605,6 +8799,14 @@ impl Game<'_> {
1.0,
1.0,
);
// HoneycombItem.useOn's game event, and ItemStack.useOn's used statistic.
self.post_block_game_event(
"minecraft:block_change",
hit.pos,
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
if !self.creative {
self.consume_held();
}
@@ -9196,10 +9398,9 @@ impl Game<'_> {
)
}
fn apply_settings(&mut self) {
if let Some(r) = &self.renderer {
self.settings
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
}
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
let abilities = self.chat_abilities();
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
self.streaming.radius = self.settings.render_distance;
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
// LevelExtractor.allChanged when they change.
@@ -9962,6 +10163,7 @@ impl Game<'_> {
self.pitch,
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
let block = [
@@ -10193,6 +10395,7 @@ impl Game<'_> {
} else {
self.shader_pack_inputs(camera, alpha as f32)
};
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -10257,6 +10460,7 @@ impl Game<'_> {
color: sky.cloud_color,
}),
entities: &entities,
entity_spans: &entity_spans,
dynamic_lights: &self.dynamic_lights,
lightning: horizon_client::gfx::lightning::Input {
bolts: &bolts,
@@ -10303,11 +10507,15 @@ impl Game<'_> {
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
Some(renderer) => horizon_client::spans::time("renderer", || renderer.render(&frame)),
Some(renderer) => {
renderer.set_pass_timing(self.debug);
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
};
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
self.see_through_vertices = see_through;
result
}
@@ -10379,6 +10587,7 @@ impl Game<'_> {
|| (self.title.is_none()
&& self.player.is_some()
&& (self.screen.blurs_background(self.pause_menu)
|| self.dialog_open()
|| (self.screen == Screen::Win && self.win_screen_pauses())));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
@@ -10431,6 +10640,9 @@ impl Game<'_> {
}
return Ok(());
}
if self.screen == Screen::Mod && self.mod_screen_key(key) {
return Ok(());
}
if self.sort_key_press(key) {
return Ok(());
}
@@ -10573,6 +10785,10 @@ impl Game<'_> {
}
/// A mouse button while playing (window or control socket).
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
// A mod's claimed item takes the use button (capability `input`).
if self.mod_use(button, pressed) {
return Ok(());
}
if self.sleeping.is_some() {
return Ok(());
}
@@ -10692,6 +10908,7 @@ impl Game<'_> {
// The loading screen's chunk map fills ring by ring, as vanilla's.
self.streaming.loading =
self.options.execution_role.is_client() && self.loading_reason().is_some();
self.tick_pregen();
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
if let Some(lod) = &mut self.lod {
@@ -11034,6 +11251,12 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Advancements {
self.advancements_drag();
}
if self.screen == Screen::Stats {
self.stats_drag();
}
if self.screen == Screen::WorldOptions {
self.world_options_drag();
}
if self.screen == Screen::Creative {
let (_, oy) = Self::creative_origin(&b);
self.creative_screen.drag_scrollbar(self.cursor.1 - oy);
@@ -11070,7 +11293,9 @@ impl ApplicationHandler for Game<'_> {
}) * wheel_sensitivity
};
let d = scaled(raw_y) as f32;
if let Some(title) = &mut self.title
if self.dialog_open() {
self.dialog_scroll(d);
} else if let Some(title) = &mut self.title
&& self.screen != Screen::Win
{
let size = self
@@ -11084,7 +11309,7 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Playing
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.extras_scroll(d)
&& !self.mod_wheel(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
@@ -11140,6 +11365,11 @@ impl ApplicationHandler for Game<'_> {
self.pack_prompt_click()?;
}
}
WindowEvent::MouseInput { state, button, .. } if self.dialog_open() => {
if state == ElementState::Pressed && button == MouseButton::Left {
self.dialog_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -11254,6 +11484,7 @@ impl ApplicationHandler for Game<'_> {
return Ok(());
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
match self.settings.keys.translate_mouse(button) {
Some(Err(b)) => self.play_mouse(b, pressed)?,
Some(Ok(k)) if pressed => {
@@ -11313,6 +11544,9 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
@@ -11336,9 +11570,8 @@ impl ApplicationHandler for Game<'_> {
.map_or([1, 1], |r| [r.config.width, r.config.height]);
self.stats_click(size);
}
Screen::Mod if pressed && button == MouseButton::Left => {
self.mod_screen_click()
}
Screen::Stats if button == MouseButton::Left => self.stats_release(),
Screen::Mod if pressed => self.mod_screen_click(button),
_ => {}
}
}
@@ -11350,6 +11583,14 @@ impl ApplicationHandler for Game<'_> {
self.answer_pack_prompt(false)?;
}
}
WindowEvent::KeyboardInput { event, .. } if self.dialog_open() => {
let shortcut = self.edit_shortcut(&event);
if event.state == ElementState::Pressed
&& let PhysicalKey::Code(key) = event.physical_key
{
self.dialog_key(key, shortcut, event.text.as_deref())?
}
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Chat && self.title.is_none() =>
{
@@ -11479,7 +11720,7 @@ impl ApplicationHandler for Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return Ok(());
};
self.extras_key(key, event.state == ElementState::Pressed, event.repeat);
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
&& event.state == ElementState::Pressed
@@ -11571,12 +11812,14 @@ impl ApplicationHandler for Game<'_> {
}
self.extras_frame(elapsed);
self.mods_frame(elapsed);
self.poll_moved_extras();
self.extras_retry()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
let ticked = Instant::now();
let rendered = self.render()?;
self.apply_cursor_icon();
if let Some(threshold) = self.options.spikes
&& let Some(r) = self.renderer.as_ref()
{
@@ -11830,6 +12073,7 @@ impl<'a> Game<'a> {
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
dialogs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
@@ -11884,6 +12128,7 @@ impl<'a> Game<'a> {
left: false,
right: false,
cursor: (0.0, 0.0),
cursor_icon: horizon_client::widgets::Cursor::Arrow,
captured: false,
focused: true,
modifiers: winit::keyboard::ModifiersState::empty(),
@@ -11902,6 +12147,8 @@ impl<'a> Game<'a> {
dynamic_lights: Vec::new(),
luminance: None,
see_through_vertices: Vec::new(),
entity_spans: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
started: now,
previous: now,
@@ -11959,6 +12206,7 @@ impl<'a> Game<'a> {
offhand: None,
hand: Hand::Main,
shield_cooldown: 0,
cooldowns: Default::default(),
command_source: None,
titles: Default::default(),
tool_highlight: Default::default(),
@@ -12074,10 +12322,12 @@ impl<'a> Game<'a> {
agent: agent::Agent::default(),
view_frustum: None,
entity_culler: None,
pack_shadow_casters: (false, false),
pending_uploads: Vec::new(),
frame_graph: Default::default(),
extras: Default::default(),
mods: Default::default(),
native_shaders: Default::default(),
sky_darken_memo: Default::default(),
persist: Default::default(),
remote: None,
@@ -12104,9 +12354,11 @@ impl<'a> Game<'a> {
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
chat_capture: None,
@@ -12836,6 +13088,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
hud,
nether_lighting: false,
entities: &[],
entity_spans: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
@@ -12910,6 +13163,7 @@ fn start_game<'a>(
// Benchmarks measure the game alone.
if game.options.benchmark.is_none() {
game.mods.dev = game.options.dev_mods.clone();
game.start_moved_extras();
game.start_mods();
}
game.start_agent_control()?;
@@ -12937,11 +13191,11 @@ fn start_game<'a>(
);
}
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
game.dispense_fixture(&spec)?;
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
return Ok(None);
}
if let Ok(spec) = std::env::var("HORIZON_SCULK_FIXTURE") {
game.sculk_fixture(&spec)?;
game.run_component_harness(integrated::ComponentHarness::Sculk(spec))?;
return Ok(None);
}
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
@@ -12963,8 +13217,8 @@ fn start_game<'a>(
Some(folder) => {
let (seed, creative) = (game.options.seed, game.options.creative);
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
// The command harness runs before entering the window loop.
game.open_world(&folder, None, Some(seed), creative, None)?;
// Headless, but with the same server and memory connection as play.
game.open_integrated(&folder, None, Some(seed), creative, None)?;
} else {
// Start after the window is handed over, so --world uses the
// same responsive initialization path as the title screen.
@@ -12991,11 +13245,10 @@ fn start_game<'a>(
game.title = Some(title);
}
}
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
) {
game.run_command_harness(script.as_ref(), output.as_ref())?;
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
&& std::env::var_os("HORIZON_COMMAND_OUTPUT").is_some()
{
game.wait_command_harness()?;
return Ok(None);
}
Ok(Some(game))
@@ -13096,10 +13349,6 @@ impl Loading {
return;
}
}
if let Some(r) = &self.renderer {
self.settings
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
}
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
self.window = Some(window);
}
@@ -13138,8 +13387,6 @@ impl Loading {
self.settings.horizon.api,
) {
Ok(r) => {
self.settings
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
self.renderer = Some(r);
}
+16
View File
@@ -390,6 +390,9 @@ impl Game<'_> {
Ok(())
}
pub(super) fn screen_label(&self) -> &'static str {
if self.dialog_open() {
return "dialog";
}
if self.title.is_some() {
return "title";
}
@@ -485,6 +488,7 @@ impl Game<'_> {
})
});
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
@@ -682,6 +686,18 @@ impl Game<'_> {
v["craft_size"] = json!(self.craft_size);
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
v["container"] = json!(self.container);
// The title a server gave the menu, span by span as it is drawn.
if let Some(title) = self.remote_menu_title() {
let spans = title.spans.iter().map(|s| {
json!({
"text": s.text,
"color": s.style.color.map(|c| format!("#{c:06X}")),
"bold": s.style.is_bold(),
"italic": s.style.is_italic(),
})
});
v["title"] = json!(spans.collect::<Vec<_>>());
}
// The recipe book's buttons (GUI pixels): the book button, then
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
@@ -8,10 +8,15 @@ use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
containers::BlockEntity,
particles::{ParticleOptions, ParticleType},
save::number,
skins,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::nbt::Tag;
/// The handle of the local player among a bell's gathered entities.
@@ -84,6 +89,16 @@ impl Game<'_> {
self.lid_openers_changed(other, open);
}
}
_ if self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
@@ -102,15 +117,48 @@ impl Game<'_> {
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let count = self.lid_openers.entry(key).or_default();
*count = (*count + if open { 1 } else { -1 }).max(0);
let count = *count;
if count == 0 {
self.lid_openers.remove(&key);
}
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
// (`TrappedChestBlock.getSignal`), so the blocks around it and
// below it hear of a change.
let state = self.world.block(pos[0], pos[1], pos[2]);
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
{
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
let block = b.block(state);
b.update_neighbors_at(level, ticks, p, block);
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
self.set_bool_prop(
key,
"open",
count > 0,
horizon_world::level::flags::UPDATE_ALL,
);
}
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
let count = self.lid_openers.entry(key).or_default();
let before = *count;
*count = (before + if open { 1 } else { -1 }).max(0);
let after = *count;
if after == 0 {
self.lid_openers.remove(&key);
}
(before, after)
}
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
/// box meets the bell's block box inflated by 48.
@@ -392,4 +440,25 @@ impl Game<'_> {
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.be_anims.lid(pos, partial)
}
/// `SkullBlockRenderer.getPlayerSkin`: the skin sprite of the profile of
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
return Some(location);
}
}
Some(skin.fallback)
}
}
+36 -8
View File
@@ -2,7 +2,10 @@
//! (game rules, the world clock, difficulty), bound to the game.
use super::{Game, Screen};
use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
chat::{
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
SYSTEM_INDICATOR,
},
command::Registries,
gamerules::GameRules,
hud::HudBatch,
@@ -425,6 +428,7 @@ impl Game<'_> {
self.ui.chat.recent.len(),
b.width as i32,
b.height as i32,
self.chat_abilities(),
);
screen.auto_suggestions = self.settings.auto_suggestions;
self.ui.chat_screen = Some(screen);
@@ -443,6 +447,10 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
}
/// A chat message from the game (ChatComponent.addServerSystemMessage).
pub(super) fn system_message(&mut self, text: Text) {
if let Some(capture) = self.chat_capture.as_mut() {
@@ -452,7 +460,11 @@ impl Game<'_> {
// Connected to a server (single player's own included), the chat on
// screen is the connection's: the game's own messages join it.
if self.remote.is_some() {
self.chat_line(text);
self.chat_line(text, ChatSource::Server);
return;
}
// The Chat option's Hidden level takes the server's messages too.
if !self.ui.chat.shows(ChatSource::Server) {
return;
}
let focused = self.screen == Screen::Chat;
@@ -461,6 +473,7 @@ impl Game<'_> {
&self.gui.atlas,
text,
Some(SYSTEM_INDICATOR),
ChatSource::Server,
self.ticks,
focused,
);
@@ -570,7 +583,12 @@ impl Game<'_> {
}
}
/// ChatScreen.handleChatInput: history, then a command or a chat line.
/// The Chat option's restrictions stop the message here too, so a
/// control-socket `chat` cannot send what the player's setting hides.
pub(super) fn handle_chat_input(&mut self, message: &str) {
if !self.chat_abilities().allows(message) {
return;
}
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
@@ -592,9 +610,14 @@ impl Game<'_> {
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
ChatSource::Player,
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
@@ -898,9 +921,14 @@ impl Game<'_> {
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
};
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, message, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
message,
None,
ChatSource::Client,
self.ticks,
focused,
);
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
+114 -27
View File
@@ -1,6 +1,7 @@
//! Headless command runner for `tools/test_command_exec_parity.py`: with
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
//! runs on its server thread with a connected, headless client. Each script
//! line is a JSON operation: `cmd` runs a
//! command as the player (Commands.performPrefixedCommand) and records every
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
@@ -41,30 +42,100 @@ pub(super) fn spans(text: &Text) -> Value {
}
impl Game<'_> {
/// Pump the real client's connection, without predicting movement: the
/// oracle's player stays where commands put it. Errors cross the server
/// boundary through Integrated::stop, including script and save failures.
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
let server = self.integrated.take().ok_or("command harness: no server")?;
while !server.stopped() {
self.poll_stationary_client()?;
std::thread::sleep(Duration::from_millis(2));
}
server.stop()
}
/// The owner's authoritative body and level, using the same swaps as a
/// server command. Always put them back, also when a script operation fails.
fn harness_owner<T>(&mut self, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let owner = self
.serving
.as_ref()
.ok_or("command harness: not a server")?
.owner;
let mut host = self.lan.take().ok_or("command harness: no hub")?;
let guest = host.hub.guests.iter().find(|g| g.uuid == owner && g.placed);
let Some(guest) = guest else {
self.lan = Some(host);
return Err("command harness: owner is not connected".into());
};
let (conn, dim, back) = (guest.conn, super::Dim::from_id(&guest.dimension), self.dim);
if let Err(error) = self.enter_level(Some(&mut host), dim) {
self.lan = Some(host);
return Err(error);
}
let swap = self
.enter_guest(&mut host, conn)
.expect("the owner is not already acting");
self.lan = Some(host);
let result = f(self);
let mut host = self.lan.take().expect("the operation puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
self.run_level_teleports(&mut host);
let restored = self.enter_level(Some(&mut host), back);
self.lan = Some(host);
restored?;
result
}
/// The player's portal process finished in the nether portal it stands
/// in (the oracle's player is not ticked, so neither is the wait).
fn harness_portal(&mut self) -> Result<()> {
let Some(p) = &self.player else {
return Err("command harness: portal without a player".into());
};
let entry = horizon_core::position::BlockPos::containing(p.position);
let block = self.world.block(entry.x, entry.y, entry.z);
if &*self.res.facts().get(block).name != "minecraft:nether_portal" {
return Err("command harness: the player is not in a nether portal".into());
}
self.player_through_nether_portal(entry)
let (conn, plan) = self.harness_owner(|g| {
let p = g
.player
.as_ref()
.ok_or("command harness: portal without a player")?;
let entry = horizon_core::position::BlockPos::containing(p.position);
let block = g.world.block(entry.x, entry.y, entry.z);
if &*g.res.facts().get(block).name != "minecraft:nether_portal" {
return Err("command harness: the player is not in a nether portal".into());
}
let plan = g.portal_plan(
entry,
p.position,
p.dimensions.width,
p.dimensions.height,
true,
);
Ok((
g.lan_acting.ok_or("command harness: no acting owner")?,
plan,
))
})?;
// Start travel after restoring the owner's body: change_level updates
// its hub record, which leave_guest would otherwise overwrite.
self.in_owner_level(|g| {
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
g.lan = Some(host);
Ok(())
})
}
/// Load terrain until the player stands in lit chunks.
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
loop {
self.update_world();
self.spawn_player()?;
if let Some(p) = &self.player {
let (cx, cz) = (
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
);
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
// Finish requested terrain before inspecting it, so worker
// timing cannot change which command positions are loaded.
if !self.streaming.busy()
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
{
// The oracle's settle also waits for the entities saved
// in those chunks (areEntitiesActuallyLoadedAndTicking):
// after a dimension change they come back from the save.
@@ -95,7 +166,19 @@ impl Game<'_> {
.and_then(|v| v.parse().ok())
.unwrap_or(600),
);
self.harness_settle(deadline)?;
// Join/configuration uses the normal hub, but never advances the world.
while !self
.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.placed && g.loaded))
{
self.tick_server(true)?;
if Instant::now() > deadline {
return Err("command harness: owner did not connect".into());
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
let mut out = String::new();
for line in std::fs::read_to_string(script)?.lines() {
if line.trim().is_empty() {
@@ -107,9 +190,12 @@ impl Game<'_> {
let command = op["cmd"].as_str().unwrap_or("");
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
self.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
self.harness_owner(|g| {
g.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
Ok(())
})?;
let messages = self.chat_capture.take().unwrap_or_default();
let results = self.cmds.results.take().unwrap_or_default();
if op["record"].as_bool() != Some(false) {
@@ -124,17 +210,8 @@ impl Game<'_> {
}
"tick" => {
self.chat_capture = Some(Vec::new());
// The oracle's player has no client moving it: hold the
// player where commands put it while the game ticks.
let held = self.player.as_ref().map(|p| p.position);
for _ in 0..op["n"].as_u64().unwrap_or(1) {
self.update_world();
self.tick()?;
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
p.position = at;
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
p.fall_distance = 0.0;
}
self.tick_server(false)?;
}
let messages = self.chat_capture.take().unwrap_or_default();
if op["record"].as_bool() == Some(true) {
@@ -147,7 +224,17 @@ impl Game<'_> {
out.push('\n');
}
}
"settle" => self.harness_settle(deadline)?,
"settle" => {
// Finish portal arrivals using connection-only passes too.
while !self.server_trips.is_empty() {
self.tick_server(true)?;
if Instant::now() > deadline {
return Err("command harness: portal did not arrive".into());
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"portal" => self.harness_portal()?,
other => return Err(format!("command harness: unknown op {other}").into()),
}
+18 -7
View File
@@ -10,6 +10,7 @@ mod items;
mod misc;
mod place;
mod players;
pub(super) mod pregen;
mod raid;
mod terrain_cmds;
mod text_commands;
@@ -751,13 +752,15 @@ impl Game<'_> {
&& !id.ends_with("get")
&& let Ok(t) = self.entities(ctx, "target", false)
{
self.cmds.waypoint_dirty.push(t[0]);
let target = self.waypoint_target(t[0]);
self.cmds.waypoint_dirty.push(target);
}
result
}
"ride.mount" | "ride.dismount" => self.cmd_ride(id, ctx),
"spreadplayers" => self.cmd_spreadplayers(ctx),
id if id.starts_with("forceload.") => self.cmd_forceload(id, ctx),
id if id.starts_with("pregen.") => self.cmd_pregen(id, ctx),
"fillbiome" => self.cmd_fillbiome(ctx),
id if id.starts_with("worldborder.") => self.cmd_worldborder(id, ctx),
id if id.starts_with("loot.") => self.cmd_loot(id, ctx),
@@ -2499,12 +2502,14 @@ impl Game<'_> {
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// Inventory.clearOrCountMatchingItems: main, armor, crafting grid, carried.
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
let mut removed = 0i32;
let mut budget = if max < 0 { i32::MAX } else { max };
let all = |g: &Self| -> Vec<Option<ItemStack>> {
let mut v: Vec<Option<ItemStack>> = g.inventory.clone();
v.extend(g.armor.iter().cloned());
v.extend(g.armor.iter().rev().cloned());
v.push(g.offhand.clone());
v.extend(g.craft_grid.iter().cloned());
v.push(g.carried.clone());
v
@@ -2518,7 +2523,8 @@ impl Game<'_> {
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
slots.extend(self.armor.iter_mut());
slots.extend(self.armor.iter_mut().rev());
slots.push(&mut self.offhand);
slots.extend(self.craft_grid.iter_mut());
slots.push(&mut self.carried);
for (slot, matches) in slots.into_iter().zip(matching) {
@@ -2836,9 +2842,14 @@ impl Game<'_> {
capture.push(line.clone());
}
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Player,
self.ticks,
focused,
);
Ok(1)
}
/// MessageArgument: `@p`-style selectors in a message become names.
@@ -57,6 +57,8 @@ pub(in crate::game) struct CommandExtras {
/// The `CommandResultCallback` results of the outermost source, when a
/// harness records them.
pub results: Option<Vec<(bool, i32)>>,
/// The running `/pregen` task.
pub pregen: Option<super::pregen::PregenTask>,
}
use horizon_core::version::{BUILD_TIME, DATA_VERSION, STABLE, VANILLA_VERSION, VERSION_ID};
@@ -212,8 +214,22 @@ impl Game<'_> {
// ---- /waypoint ------------------------------------------------------
/// A guest's body is only `Player` during its turn; retained command state
/// must use its connection so the next server tick still finds that body.
pub(super) fn waypoint_target(&self, t: Target) -> Target {
match t {
Target::Player => self.lan_acting.map_or(t, Target::Guest),
_ => t,
}
}
/// A living entity's `waypoint_transmit_range` (`isTransmittingWaypoint`).
fn transmit_range(&mut self, t: Target) -> f64 {
if matches!(t, Target::Guest(_)) {
return self
.for_player(t, |g| g.transmit_range(Target::Player))
.unwrap_or(0.0);
}
if t == Target::Player {
return self
.player
@@ -242,7 +258,7 @@ impl Game<'_> {
fn waypoint_transmitters(&mut self) -> Vec<Target> {
if self.cmds.waypoints.is_none() {
let tracked = if self.transmit_range(Target::Player) > 0.0 {
vec![Target::Player]
vec![self.waypoint_target(Target::Player)]
} else {
Vec::new()
};
@@ -255,6 +271,12 @@ impl Game<'_> {
.collect();
self.cmds.waypoints = Some(alive.clone());
alive
.into_iter()
.map(|t| match t {
Target::Guest(conn) if self.lan_acting == Some(conn) => Target::Player,
_ => t,
})
.collect()
}
/// The waypoint part of a living entity's tick: dirty transmit ranges
/// track or untrack it.
@@ -277,7 +299,7 @@ impl Game<'_> {
fn waypoint_icon(&self, t: Target) -> WaypointIcon {
self.cmds
.waypoint_icons
.get(&t)
.get(&self.waypoint_target(t))
.cloned()
.unwrap_or_default()
}
@@ -387,6 +409,7 @@ impl Game<'_> {
&[Text::colored(name, rgb)],
)
};
let t = self.waypoint_target(t);
self.cmds.waypoint_icons.insert(t, icon);
// WaypointCommand.mutateIcon: untrack, change, track again.
self.waypoint_transmitters();
@@ -606,6 +629,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| lan.hub.singleplayer_owner != Some(g.uuid))
.filter(|g| match &s.player_name {
Some(name) => g.name.eq_ignore_ascii_case(name),
None => s.kind == 'a' || s.kind == 'e',
@@ -617,7 +641,7 @@ impl Game<'_> {
if host.is_empty() && guests.is_empty() {
return Err(err("argument.entity.notfound.player", &[]));
}
if self.lan.is_none() {
if self.lan.is_none() || (self.serving() && self.published_port().is_none()) {
return Err(err("commands.kick.singleplayer.failed", &[]));
}
let reason_raw = match ctx.get("reason") {
@@ -0,0 +1,276 @@
//! `/pregen`, Horizon's own command: generate, light and save every chunk of a
//! square or a circle ahead of the players (docs/pregeneration.md). The task
//! lives on the server and hands its band to the level's streaming, whose
//! workers do the work; chunks behind the band unload and are saved as any
//! chunk the players leave.
use super::{CommandError, Context, Dim, Game, R};
use horizon_client::{
lan::data::component_nbt,
pregen::{Pregen, Shape},
text::{self, Text},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use std::time::{Duration, Instant};
/// How often the progress is reported.
const REPORT_EVERY: Duration = Duration::from_secs(5);
/// How long the band may stay put before the task looks for what holds it.
const STALL: Duration = Duration::from_secs(30);
pub(in crate::game) struct PregenTask {
pregen: Pregen,
dim: Dim,
paused: bool,
/// Time spent running, up to the last pause, and when it last resumed.
ran: Duration,
resumed: Instant,
reported: Instant,
/// When the band last moved, to notice a task that stopped advancing.
moved: Instant,
/// The player who started it, told the progress too (a guest's connection).
owner: Option<u32>,
}
impl PregenTask {
fn running_for(&self) -> Duration {
if self.paused {
self.ran
} else {
self.ran + self.resumed.elapsed()
}
}
fn progress(&self) -> Text {
let p = &self.pregen;
let seconds = self.running_for().as_secs_f64();
let rate = p.done as f64 / seconds.max(1e-3);
let left = p.total - p.done;
let eta = if rate > 0.0 {
duration(left as f64 / rate)
} else {
"unknown".into()
};
Text::literal(format!(
"Pregenerating {}: {} of {} chunks ({:.1}%), {rate:.1} chunks/s, {} left",
self.dim.id(),
p.done,
p.total,
100.0 * p.done as f64 / p.total.max(1) as f64,
if p.done == p.total {
"nothing".into()
} else {
eta
},
))
}
}
fn failure(message: &str) -> CommandError {
CommandError::new(Text::literal(message))
}
/// `1h 02m 03s`.
fn duration(seconds: f64) -> String {
let s = seconds.round() as u64;
match (s / 3600, s / 60 % 60, s % 60) {
(0, 0, s) => format!("{s}s"),
(0, m, s) => format!("{m}m {s:02}s"),
(h, m, s) => format!("{h}h {m:02}m {s:02}s"),
}
}
impl Game<'_> {
pub(super) fn cmd_pregen(&mut self, id: &str, ctx: &Context) -> R<i32> {
match id {
"pregen.start" => {
if self.cmds.pregen.is_some() {
return Err(failure(
"Pregeneration is already running: /pregen cancel stops it",
));
}
let radius = Self::int(ctx, "radius").unwrap_or(0);
let shape = if ctx.literals.iter().any(|l| l == "circle") {
Shape::Circle
} else {
Shape::Square
};
let [x, z] = if ctx.get("center").is_some() {
self.column_arg(ctx, "center")?
} else {
let p = self.source_position();
[p[0].floor() as i32, p[2].floor() as i32]
};
let pregen = Pregen::new((x >> 4, z >> 4), radius >> 4, shape);
let now = Instant::now();
let total = pregen.total;
self.feedback(Text::literal(format!(
"Pregenerating {total} chunks of {}: a {} of radius {radius} around {x} {z}",
self.dim.id(),
match shape {
Shape::Square => "square",
Shape::Circle => "circle",
},
)));
self.cmds.pregen = Some(PregenTask {
pregen,
dim: self.dim,
paused: false,
ran: Duration::ZERO,
resumed: now,
reported: now,
moved: now,
owner: self.lan_acting,
});
Ok(total as i32)
}
"pregen.pause" | "pregen.continue" => {
let pause = id == "pregen.pause";
let task = self
.cmds
.pregen
.as_mut()
.ok_or_else(|| failure("No pregeneration is running"))?;
if task.paused != pause {
task.paused = pause;
if pause {
task.ran += task.resumed.elapsed();
} else {
task.resumed = Instant::now();
}
}
let (progress, dim) = (task.progress(), task.dim);
if pause && self.dim == dim {
self.streaming.set_band(None);
}
self.feedback(
Text::literal(if pause { "Paused. " } else { "Continuing. " }).append(progress),
);
Ok(1)
}
"pregen.cancel" => {
let task = self
.cmds
.pregen
.take()
.ok_or_else(|| failure("No pregeneration is running"))?;
if self.dim == task.dim {
self.streaming.set_band(None);
}
self.feedback(Text::literal("Cancelled. ").append(task.progress()));
Ok(task.pregen.done as i32)
}
_ => {
let task = self
.cmds
.pregen
.as_ref()
.ok_or_else(|| failure("No pregeneration is running"))?;
let progress = task.progress();
let done = task.pregen.done as i32;
self.feedback(progress);
Ok(done)
}
}
}
/// Move the task's band past the rows that are lit and hand it to the
/// level's streaming, and report the progress now and then. Runs before
/// each streaming update of the level in place.
pub(in crate::game) fn tick_pregen(&mut self) {
let Some(task) = self.cmds.pregen.as_mut() else {
return;
};
if task.dim != self.dim || task.paused {
return;
}
// A row is done once its chunks are lit and post-processed
// (`postprocess_chunks`, on the tick), as vanilla's are by the time
// they are FULL: then they may unload and be saved.
let (streaming, chunks) = (&mut self.streaming, &self.world.chunks);
if task.pregen.advance(|x, z| {
streaming.is_lit(x, z)
&& chunks
.get(&(x, z))
.is_some_and(|c| c.postprocess.is_empty())
}) {
task.moved = Instant::now();
}
streaming.set_band(task.pregen.band());
if task.moved.elapsed() >= STALL {
// Nothing advanced for a while: say what the first row waits
// for, and queue light again for chunks that lost their job.
task.moved = Instant::now();
let row = task.pregen.first_row();
let waiting = row
.iter()
.filter(|k| chunks.get(k).is_some_and(|c| !c.postprocess.is_empty()))
.count();
let [missing, lighting, unlit] = streaming.unstick_light(&mut self.world, &row);
log::warn!(
"Horizon: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \
{unlit} unlit with no light job (queued again), {waiting} not post-processed; {:?}",
row.first(),
streaming.stages()
);
}
let finished = task.pregen.finished();
if finished || task.reported.elapsed() >= REPORT_EVERY {
task.reported = Instant::now();
let (owner, mut message) = (task.owner, task.progress());
if finished {
message = Text::literal("Done. ")
.append(message)
.with_color(text::GREEN);
self.cmds.pregen = None;
}
self.pregen_report(owner, message);
}
}
/// Whether the level in place has a running task, which the server pumps
/// between its ticks (`pump_pregen`).
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
self.cmds
.pregen
.as_ref()
.is_some_and(|t| t.dim == self.dim && !t.paused)
}
/// The level a running task needs ticked, whoever is in it.
pub(in crate::game) fn pregen_level(&self) -> Option<Dim> {
self.cmds.pregen.as_ref().map(|t| t.dim)
}
/// Keep the workers fed until `until`: collect what they finished and
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
let now = Instant::now();
if now >= until {
break;
}
self.streaming
.wait((until - now).min(Duration::from_millis(5)));
self.update_world();
}
}
/// The console and the player who started the task get the progress.
fn pregen_report(&mut self, owner: Option<u32>, message: Text) {
if self.options.dedicated.is_some() {
super::super::dedicated::log("INFO", &message.plain());
} else {
log::info!("{}", message.plain());
}
if let Some(host) = &self.lan {
if let Some(g) = owner.and_then(|c| host.hub.guest(c)) {
g.send(&C::SystemChat(play::SystemChat {
content: component_nbt(&message.component()),
overlay: false,
}));
}
} else if self.options.dedicated.is_none() {
self.system_message(message);
}
}
}
@@ -30,7 +30,7 @@ fn ticks_to_seconds(ticks: i64) -> String {
impl Game<'_> {
// ---- /forceload ----------------------------------------------------------
fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
pub(super) fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos2d.incomplete", &[]))?;
+20 -4
View File
@@ -241,6 +241,13 @@ impl Game<'_> {
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
// A server's dialog takes the keys, in configuration too.
if self.dialog_open() {
if arg(2)? != "up" {
self.dialog_key(key, None, None)?;
}
return ok();
}
self.require_world()?;
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
if self.sign_edit.is_some() {
@@ -288,11 +295,12 @@ impl Game<'_> {
self.debug_key(key, KeyAction::Release);
}
}
// The zoom extra sees the key as the keyboard hook does.
// Mods' keys (capability `input`) see the key as the keyboard
// hook does.
let action = arg(2)?;
self.extras_key(physical, action != "up", action == "repeat");
self.mod_key(physical, action != "up", action == "repeat");
if action == "tap" {
self.extras_key(physical, false, false);
self.mod_key(physical, false, false);
}
// Text screens consume raw presses; releases follow key binds.
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
@@ -367,15 +375,19 @@ impl Game<'_> {
match arg(2)? {
"down" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.play_mouse(button, true)?
}
"up" => {
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, false)?
}
"click" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, true)?;
self.play_mouse(button, false)?;
}
@@ -533,13 +545,17 @@ impl Game<'_> {
))
}
"type" => {
self.require_world()?;
let text = line
.trim_start()
.strip_prefix("type")
.unwrap_or("")
.strip_prefix(' ')
.unwrap_or("");
// A server's dialog, in configuration too.
if self.dialog_type(text) {
return ok();
}
self.require_world()?;
if self.book.is_some() {
self.book_type(text);
return ok();
+10 -18
View File
@@ -43,10 +43,6 @@ fn arrow_damage(arrow: &mut Projectile, fallback: &mut impl RandomSource) -> f32
#[derive(Default)]
pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Player status effects applied by mobs: (effect, ticks left, amplifier).
pub pearl_cooldown: u32,
/// The wind charge's use cooldown (ticks).
pub wind_cooldown: u32,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
@@ -642,7 +638,6 @@ impl Game<'_> {
self.tick_allays();
self.tick_breeze_effects();
self.tick_sniffer_effects();
self.tick_player_effects();
self.piglin_pickups();
self.sulfur_cube_pickups();
// Hurt and death sounds of damage dealt outside the mob ticks.
@@ -1572,13 +1567,6 @@ impl Game<'_> {
}
self.give_player_effect("minecraft:wither", 40, 0);
}
/// Burning applied to the player by mobs.
fn tick_player_effects(&mut self) {
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
if self.creatures.pearl_cooldown > 0 {
self.creatures.pearl_cooldown -= 1;
}
}
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
if self.creative {
return;
@@ -2879,14 +2867,14 @@ impl Game<'_> {
let kind = match item {
"minecraft:snowball" => ProjectileKind::Snowball,
"minecraft:egg" | "minecraft:blue_egg" | "minecraft:brown_egg" => ProjectileKind::Egg,
"minecraft:ender_pearl" => {
if self.creatures.pearl_cooldown > 0 {
return Ok(true);
"minecraft:ender_pearl" => ProjectileKind::EnderPearl,
"minecraft:wind_charge" => {
let thrown = self.throw_wind_charge()?;
if thrown {
self.apply_use_cooldown(stack);
}
self.creatures.pearl_cooldown = 20;
ProjectileKind::EnderPearl
return Ok(thrown);
}
"minecraft:wind_charge" => return self.throw_wind_charge(),
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
_ => return Ok(false),
};
@@ -2911,6 +2899,9 @@ impl Game<'_> {
);
self.creatures.projectiles.push(proj);
self.player_projectile_shot();
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
// second is the `use_cooldown` component, as for any thrown item.
self.apply_use_cooldown(stack);
let sound = match kind {
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
ProjectileKind::Egg => "minecraft:entity.egg.throw",
@@ -3031,6 +3022,7 @@ impl Game<'_> {
continue;
}
}
self.mark_entity(entity_start, &mob.kind.id);
let light = self.world.light(
mob.position.x.floor() as i32,
(mob.position.y + mob.eye_height()).floor() as i32,
@@ -415,6 +415,13 @@ impl Game<'_> {
],
);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
});
if !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
// tps: DebugEntryTps for local and remote connections.
if self.player.is_some() {
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
+11 -1
View File
@@ -155,6 +155,14 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
for w in &props.warnings {
log("WARN", w);
}
// Horizon's own commands are the owner's choice, off as on vanilla.
if props
.raw
.get(horizon_client::command::vanilla::PREGEN_PROPERTY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
{
horizon_client::command::vanilla::set_horizon_commands(true);
}
props.store(&settings)?;
let eula_file = PathBuf::from("eula.txt");
let agreed = match eula::agreed(&eula_file) {
@@ -667,7 +675,9 @@ impl Game<'_> {
self.poll_server_control(&mut state);
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
if pause_after > 0 {
if players == 0 {
// A `/pregen` task keeps the server busy as a player would:
// paused, nothing would post-process its chunks.
if players == 0 && self.pregen_level().is_none() {
state.empty_ticks += 1;
} else {
state.empty_ticks = 0;
+611
View File
@@ -0,0 +1,611 @@
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
//! `WaitingForResponseScreen`): the screen over whatever is open (the
//! connecting screen in configuration), its inputs, and the actions its
//! buttons send back.
use super::Game;
use horizon_client::{
Result,
clipboard::EditShortcut,
confirm_screen::Confirm,
dialog::{AfterAction, Click, Control, Dialog, InputValue},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
ui,
};
use horizon_world::nbt::Tag;
use std::time::Instant;
use winit::keyboard::KeyCode;
/// An open `DialogScreen`.
struct Open {
dialog: Dialog,
values: Vec<InputValue>,
/// The box of each text input.
fields: Vec<Option<ui::EditBox>>,
focus: Option<usize>,
scroll: ui::Scroll,
}
/// The dialog screens a server can open.
#[derive(Default)]
pub(super) struct DialogState {
open: Option<Open>,
/// `WaitingForResponseScreen`, since when.
waiting: Option<Instant>,
/// `DialogScreen.WarningScreen`, over the dialog.
warning: bool,
}
fn warning_texts() -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
t("menu.custom_screen_info.title"),
t("menu.custom_screen_info.contents"),
[t("menu.disconnect"), t("gui.back")],
)
}
impl Game<'_> {
/// Whether a dialog screen takes the input.
pub(super) fn dialog_open(&self) -> bool {
let d = &self.dialogs;
d.open.is_some() || d.waiting.is_some() || d.warning
}
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
pub(super) fn show_dialog(&mut self, tag: &Tag) {
let Some(dialog) = Dialog::parse(tag) else {
log::warn!("Horizon: failed to show dialog for data {tag:?}");
return;
};
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let fields = dialog
.inputs
.iter()
.map(|i| match &i.control {
Control::Text {
width,
initial,
max_length,
..
} => {
let mut edit = ui::EditBox::new(0.0, 0.0, *width as f32, 20.0);
edit.max_length = (*max_length).max(1) as usize;
edit.set_value(initial);
edit.focused = false;
Some(edit)
}
_ => None,
})
.collect();
self.dialogs.open = Some(Open {
dialog,
values,
fields,
focus: None,
scroll: ui::Scroll::default(),
});
self.dialogs.waiting = None;
self.capture(false);
}
/// `clearDialog`: the dialog closes (under the warning screen, Back then
/// returns to what was open before it).
pub(super) fn clear_dialog(&mut self) {
if self.dialogs.open.take().is_some() && !self.dialog_open() {
self.dialog_closed();
}
}
/// Every dialog screen goes (a disconnect, or the level loading screen).
pub(super) fn close_dialogs(&mut self) {
self.dialogs = DialogState::default();
}
fn dialog_closed(&mut self) {
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
self.capture(true);
}
}
/// The input values as the controls hold them now.
fn dialog_values(open: &mut Open) {
for (value, field) in open.values.iter_mut().zip(&open.fields) {
if let Some(f) = field {
*value = InputValue::Text(f.value.clone());
}
}
}
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
AfterAction::WaitForResponse => {
self.dialogs.open = None;
self.dialogs.waiting = Some(Instant::now());
}
}
match click {
Some(Click::Custom { id, payload }) => {
if let Some(r) = &self.remote {
r.session.send_custom_click(&id, payload);
}
}
Some(Click::RunCommand(command)) => {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
let command = command.strip_prefix('/').unwrap_or(&command);
self.send_chat(&format!("/{command}"));
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
);
}
}
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
Some(Click::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
None => {}
}
if !self.dialog_open() {
self.dialog_closed();
}
Ok(())
}
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
fn show_dialog_ref(&mut self, dialog: &Tag) {
match dialog {
Tag::String(id) => {
let id = id.to_string_lossy();
let name = if id.contains(':') {
id.clone()
} else {
format!("minecraft:{id}")
};
let found = self.remote.as_ref().and_then(|r| {
let entries = r.registries.registries.get("minecraft:dialog")?;
entries.iter().find(|(n, _)| *n == name)?.1.clone()
});
match found {
Some(tag) => self.show_dialog(&tag),
None => log::warn!("Horizon: unknown dialog {id}"),
}
}
tag => self.show_dialog(tag),
}
}
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
pub(super) fn show_play_dialog(
&mut self,
dialog: &horizon_protocol::packets::play::DialogHolder,
) {
use horizon_protocol::packets::play::DialogHolder;
match dialog {
DialogHolder::Direct(tag) => self.show_dialog(tag),
DialogHolder::Registry(id) => {
let tag = self
.remote
.as_ref()
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
match tag {
Some(tag) => self.show_dialog(&tag),
None => log::warn!("Horizon: unknown dialog {id}"),
}
}
}
}
fn dialog_layout(&self) -> Option<Layout> {
let open = self.dialogs.open.as_ref()?;
Some(Layout::new(
&open.dialog,
&self.gui.atlas.font,
self.gui_size(),
))
}
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
if !self.dialog_open() {
return;
}
let (w, h) = (batch.width, batch.height);
let cursor = self.cursor;
let atlas = &self.gui.atlas;
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
if self.dialogs.warning {
let (title, message, [disconnect, back]) = warning_texts();
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
batch,
atlas,
cursor,
[&disconnect, &back],
);
return;
}
if let Some(since) = self.dialogs.waiting {
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
let ticks = (since.elapsed().as_millis() / 50) as u32;
let (label, visible, active) = waiting_button(ticks);
let title = Text::translate("gui.waitingForResponse.title", &[]);
let top = ((h - 53.0) / 2.0).trunc();
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
((w - tw) / 2.0).trunc(),
top + 12.0,
u32::MAX,
true,
);
if visible {
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
}
return;
}
let Some(layout) = self.dialog_layout() else {
return;
};
let Some(open) = self.dialogs.open.as_mut() else {
return;
};
let atlas = &self.gui.atlas;
let [ax, ay, aw, ah] = layout.area;
open.scroll.clamp(ay, ay + ah, layout.content);
batch.text_styled(
atlas,
&open.dialog.title,
layout.title.0,
layout.title.1,
u32::MAX,
true,
);
let [wx, wy, ww, wh] = layout.warning;
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
horizon_client::widgets::record("warning", layout.warning, true);
batch.sprite_scaled(
atlas,
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
wx,
wy,
ww,
wh,
u32::MAX,
);
let buttons = open.dialog.buttons();
let in_area = |y: f32| y >= ay && y < ay + ah;
let mut tooltip = None;
let mut start = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && start.is_none() {
start = Some(batch.vertices.len());
} else if !clipped && let Some(s) = start.take() {
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
}
match part {
Part::Line(text, x, y) => {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
}
Part::Field { input, rect } => {
if let Some(Some(edit)) = open.fields.get_mut(input) {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.focused = open.focus == Some(input);
edit.draw(batch, atlas, None);
}
}
Part::Check { input, rect } => {
let [x, y, cw, ch] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
let on = open.values.get(input) == Some(&InputValue::Bool(true));
let sprite = match (on, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
(true, false) => "gui/sprites/widget/checkbox_selected",
(false, true) => "gui/sprites/widget/checkbox_highlighted",
(false, false) => "gui/sprites/widget/checkbox",
};
let Control::Boolean { label, .. } = &open.dialog.inputs[input].control else {
continue;
};
horizon_client::widgets::record(&label.plain(), rect, true);
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
batch.text_styled(
atlas,
label,
x + BOX + 4.0,
y + 4.0,
rgba(224, 224, 224, 255),
true,
);
}
Part::Button { index, rect } => {
let [x, y, bw, bh] = rect;
let hover =
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
let b = buttons[index];
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
if hover && let Some(t) = &b.tooltip {
tooltip = Some(t.clone());
}
}
}
}
if let Some(s) = start {
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
}
if layout.content > ah {
ui::scrollbar(
batch,
atlas,
ax + aw - 6.0,
ay,
ay + ah,
layout.content,
open.scroll.amount,
);
}
if warn_hover {
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
}
if let Some(t) = tooltip {
let lines: Vec<Text> = atlas
.font
.split_lines(&t, 170.0)
.into_iter()
.map(|l| l.0)
.collect();
ui::tooltip(batch, atlas, &lines, cursor);
}
}
/// A left click on a dialog screen.
pub(super) fn dialog_click(&mut self) -> Result<()> {
let cursor = self.cursor;
if self.dialogs.warning {
let (title, message, _) = warning_texts();
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
.answer_at(cursor);
match answer {
Some(true) => {
self.audio.play_button_click();
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
return self.leave_server(Some(reason.plain()));
}
Some(false) => {
self.audio.play_button_click();
self.dialogs.warning = false;
if !self.dialog_open() {
self.dialog_closed();
}
}
None => {}
}
return Ok(());
}
if let Some(since) = self.dialogs.waiting {
let (w, h) = self.gui_size();
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
let (bx, by) = (
((w - 200.0) / 2.0).trunc(),
((h - 53.0) / 2.0).trunc() + 33.0,
);
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
self.audio.play_button_click();
self.dialogs.waiting = None;
self.dialog_closed();
}
return Ok(());
}
let Some(layout) = self.dialog_layout() else {
return Ok(());
};
let [wx, wy, ww, wh] = layout.warning;
if ui::hovered(cursor, wx, wy, ww, wh) {
self.audio.play_button_click();
self.dialogs.warning = true;
return Ok(());
}
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
let [_, ay, _, ah] = layout.area;
let mut action = None;
open.focus = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
continue;
}
match part {
Part::Field {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.focus = Some(input);
if let Some(Some(edit)) = open.fields.get_mut(input) {
edit.focused = true;
edit.click(&self.gui.atlas, cursor.0, false, false);
}
}
Part::Check {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
if let Some(InputValue::Bool(b)) = open.values.get_mut(input) {
*b = !*b;
}
action = Some(None);
}
Part::Button {
index,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
action = Some(Some(index));
}
_ => {}
}
}
let Some(action) = action else {
return Ok(());
};
self.audio.play_button_click();
let Some(index) = action else {
return Ok(());
};
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
Self::dialog_values(open);
let d = &open.dialog;
let click = d.buttons()[index]
.action
.as_ref()
.and_then(|a| a.click(&d.inputs, &open.values));
let after = d.after_action;
self.run_dialog_action(click, after)
}
/// The mouse wheel over a dialog: its body scrolls 10 a step.
pub(super) fn dialog_scroll(&mut self, delta: f32) {
let Some(layout) = self.dialog_layout() else {
return;
};
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
/// moves between the text inputs, the rest goes to the focused one
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
pub(super) fn dialog_key(
&mut self,
key: KeyCode,
shortcut: Option<EditShortcut>,
text: Option<&str>,
) -> Result<()> {
let escape = key == KeyCode::Escape;
if self.dialogs.warning {
if escape {
self.dialogs.warning = false;
if !self.dialog_open() {
self.dialog_closed();
}
}
return Ok(());
}
if let Some(since) = self.dialogs.waiting {
// shouldCloseOnEsc: once Back works.
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
self.dialogs.waiting = None;
self.dialog_closed();
}
return Ok(());
}
let ctrl = self.modifiers.control_key() || self.modifiers.super_key();
let shift = self.modifiers.shift_key();
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
if escape {
if !open.dialog.can_close_with_escape {
return Ok(());
}
// onClose: the cancel action, and the screen closes.
Self::dialog_values(open);
let d = &open.dialog;
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
return self.run_dialog_action(click, AfterAction::Close);
}
if key == KeyCode::Tab {
let texts: Vec<usize> = (0..open.fields.len())
.filter(|&i| open.fields[i].is_some())
.collect();
if !texts.is_empty() {
let at = open.focus.and_then(|f| texts.iter().position(|&i| i == f));
let next = match at {
None if shift => texts.len() - 1,
None => 0,
Some(p) if shift => (p + texts.len() - 1) % texts.len(),
Some(p) => (p + 1) % texts.len(),
};
open.focus = Some(texts[next]);
}
return Ok(());
}
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
return Ok(());
};
if let Some(shortcut) = shortcut {
edit.shortcut(shortcut, &mut self.clipboard);
return Ok(());
}
if edit.key(key, ctrl, shift) {
return Ok(());
}
let typed: String = text
.unwrap_or_default()
.chars()
.filter(|c| !c.is_control())
.collect();
if !typed.is_empty() {
edit.insert_text(&typed);
}
Ok(())
}
/// `type TEXT` with a dialog open: into the focused text input, or the
/// first one.
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
let Some(open) = self.dialogs.open.as_mut() else {
return false;
};
let focus = open
.focus
.or_else(|| open.fields.iter().position(Option::is_some));
let Some(Some(edit)) = focus.and_then(|f| open.fields.get_mut(f)) else {
return false;
};
open.focus = focus;
edit.insert_text(text);
true
}
/// What `state` reports: the open dialog and its values, or the waiting
/// and warning screens.
pub(super) fn dialog_status(&self) -> serde_json::Value {
let d = &self.dialogs;
let screen = if d.warning {
Some("warning")
} else if d.waiting.is_some() {
Some("waiting_for_response")
} else {
None
};
let open = d.open.as_ref().map(|open| {
let mut values = open.values.clone();
for (value, field) in values.iter_mut().zip(&open.fields) {
if let Some(f) = field {
*value = InputValue::Text(f.value.clone());
}
}
open.dialog.json(&values)
});
serde_json::json!({ "open": open, "screen": screen })
}
}
@@ -90,6 +90,10 @@ enum Target {
impl Game<'_> {
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
assert!(
!self.options.execution_role.is_client(),
"fixture requires a server Game"
);
let (input, output) = spec
.split_once(',')
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
+4 -3
View File
@@ -1971,9 +1971,10 @@ impl Game<'_> {
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
}
if t == 1 {
// Level event 1028: the death roar, heard everywhere.
self.audio
.play("minecraft:entity.ender_dragon.death", None, 5.0, 1.0);
// EnderDragon.tickDeath: global level event 1028, the death
// roar, heard everywhere.
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
self.global_level_event(1028, at, 0);
}
if let Some(d) = self.dragon.as_mut() {
d.position.y += 0.1;
+4 -3
View File
@@ -316,9 +316,10 @@ impl Game<'_> {
)?;
}
}
// Global LevelEvent 1038: END_PORTAL_SPAWN.
self.audio
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
// EnderEyeItem.useOn: global level event 1038 (END_PORTAL_SPAWN),
// heard by every player (vanilla places it at the frame's corner
// + (1, 0, 1); only its direction matters).
self.global_level_event(1038, c, 0);
}
Ok(true)
}
@@ -15,6 +15,11 @@ fn opaque(facts: &StateFacts) -> bool {
impl Game<'_> {
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
(Some(r), Some(_)) => r.pack_shadow_casters(),
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
self.entity_culler = (self.settings.horizon.entity_culling
// Packs can replace an opaque block's model or texture.
@@ -28,24 +33,29 @@ impl Game<'_> {
/// Use complete generated geometry so equipment, animation and custom
/// models cannot stick out of an assumed collision-sized culling box.
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
if let Some(culler) = &self.entity_culler {
culler.cull_vertices(vertices, start, |pos| {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(MIN_Y) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
// Newly edited or not-yet-uploaded terrain is not an occluder.
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
});
let Some(culler) = &self.entity_culler else {
return;
};
let hidden = culler.hidden(vertices, start, |pos| {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(MIN_Y) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
// Newly edited or not-yet-uploaded terrain is not an occluder.
self.renderer
.as_ref()
.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
});
// A shader pack's shadow pass still draws what the camera cannot see.
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
vertices.truncate(start);
}
}
}
+74 -98
View File
@@ -1,24 +1,22 @@
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
//! clicks for the keystroke overlay, frame times for the FPS counter, the
//! minimap's tiles and image, and their HUD. Each hook does nothing while its
//! extra is off.
//! 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
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
//! Each hook does nothing while its extra is off and no mod runs.
use super::{Dim, Game, Screen};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
extras_hud::{self, Corners, Keystrokes},
extras::{Corner, Cps, FpsCounter, fov_target},
extras_hud::{self, Corners},
hud::{HudBatch, MINIMAP_SPRITE},
keybinds,
minimap::{self, Layer, Minimap, View},
multiplayer,
reconnect::{Retry, Tracker},
title::Mode,
};
use std::time::Duration;
use winit::{event::MouseButton, keyboard::KeyCode};
use winit::event::MouseButton;
#[derive(Default)]
pub(super) struct ExtrasState {
zoom: Zoom,
fps: FpsCounter,
/// Left and right mouse buttons: held, and their clicks.
mouse: [bool; 2],
@@ -36,41 +34,16 @@ impl Game<'_> {
fn extras_now(&self) -> f64 {
self.started.elapsed().as_secs_f64()
}
fn zoom_key(&self) -> Option<KeyCode> {
let e = &self.settings.extras;
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
}
/// A key press or release. The zoom key zooms only while playing with
/// no text open; the key itself always goes on to the screen and the key
/// binds, as a vanilla key mapping's would.
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if self.zoom_key() != Some(key) {
return;
}
let playing = self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none();
let level = self.settings.extras.zoom_level;
self.extras.zoom.key(pressed, repeat, playing, level);
}
/// Wheel steps while playing; true when zooming took them from the hotbar.
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
}
/// Mouse buttons while playing, for zoom and the keystroke overlay.
/// Mouse buttons while playing, for mods' clicks.
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
let e = &self.settings.extras;
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
self.extras.zoom.key(pressed, false, true, e.zoom_level);
}
let i = match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
_ => return,
};
self.extras.mouse[i] = pressed;
if pressed && self.settings.extras.keystrokes {
// For mods (`input.clicks-per-second`).
if pressed && self.mods.running() {
let now = self.extras_now();
self.extras.cps[i].click(now);
}
@@ -80,20 +53,32 @@ impl Game<'_> {
// Under agent control the window may lack focus without pausing.
let focused = self.focused || self.control.is_some();
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
if !playing || !self.settings.extras.zoom {
self.extras.zoom.release();
}
if !playing {
self.extras.mouse = [false; 2];
}
self.update_minimap();
if self.settings.extras.fps {
// For mods (`render.frame-stats`).
if self.mods.running() {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
} else {
self.extras.fps.clear();
}
}
/// The frame counter for mods: fps, min, 1% low.
#[cfg(feature = "mods")]
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
let r = self.extras.fps.reading();
(r.fps, r.min, r.low)
}
/// Attack and use clicks in the last second, and whether each is held.
#[cfg(feature = "mods")]
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
let now = self.extras_now();
let [l, r] = &mut self.extras.cps;
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
(cps, self.extras.mouse)
}
/// Auto-reconnect: a connection to `address` as `name` starts.
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
self.extras.reconnect.connecting(address, name);
@@ -143,25 +128,44 @@ impl Game<'_> {
}
Ok(())
}
/// The minimap's view of the player, if it shows.
fn minimap_view(&self) -> Option<View> {
let e = &self.settings.extras;
/// The minimap 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.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
let p = self.player.as_ref()?;
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
shown.then_some(View {
if self.title.is_some() || self.ui.hide_gui {
return None;
}
let 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 view = View {
x: p.position.x,
z: p.position.z,
yaw: self.yaw,
rotate: e.minimap_rotate,
blocks_per_pixel: e.minimap_zoom,
round: e.minimap_round,
})
rotate,
blocks_per_pixel,
round,
};
Some((corner, size, view))
}
/// Refresh the minimap's tiles within the frame budget, recompose its
/// image when the view or a tile changed, and queue the upload.
fn update_minimap(&mut self) {
let Some(view) = self.minimap_view() else {
if !self.settings.extras.minimap || self.player.is_none() {
let Some((_, _, view)) = self.minimap_shown() else {
let wanted = self.settings.extras.minimap || self.mod_minimap().is_some();
if !wanted || self.player.is_none() {
self.extras.minimap.clear();
self.extras.minimap_dim = None;
}
@@ -209,45 +213,43 @@ impl Game<'_> {
rgba: self.extras.minimap.image.clone(),
});
}
/// The zoom in effect (1 unless the key is held).
fn zoom_factor(&self) -> f32 {
self.extras.zoom.factor(self.scoping())
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
/// scale); none is 1.
fn zoom_now(&self) -> (f32, bool, f64) {
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
(z.factor, z.smooth, f64::from(z.turn))
})
}
/// The field-of-view modifier target, divided by a smooth zoom.
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
if self.settings.extras.zoom_smooth {
fov_target(vanilla, self.zoom_factor())
} else {
vanilla
match self.zoom_now() {
(factor, true, _) => fov_target(vanilla, factor),
_ => vanilla,
}
}
/// The field of view in degrees, divided by an instant zoom.
pub(super) fn zoomed_fov_degrees(&self, fov: f32) -> f32 {
if self.settings.extras.zoom_smooth {
fov
} else {
fov / self.zoom_factor()
match self.zoom_now() {
(factor, false, _) => fov / factor,
_ => fov,
}
}
/// Mouse turning while zoomed.
pub(super) fn zoom_turn_scale(&self) -> f64 {
turn_scale(self.zoom_factor(), self.settings.extras.zoom_sensitivity)
self.zoom_now().2
}
/// The FPS counter and keystrokes, in the HUD layer (hidden with F1 by
/// the caller). The top left corner gives way to the debug screen.
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
/// by the caller). The top left corner gives way to the debug screen.
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let e = &self.settings.extras;
let mod_panels = self.mods.has_panels();
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
let map = self.minimap_shown();
if map.is_none() && !mod_panels {
return;
}
let (fps_on, fps_corner, lows) = (e.fps, e.fps_corner, e.fps_lows);
let (keys_on, keys_corner, cps_on) = (e.keystrokes, e.keystrokes_corner, e.keystrokes_cps);
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
let atlas = &self.gui.atlas;
if let Some(view) = self.minimap_view()
if let Some((map_corner, map_size, view)) = map
&& visible(map_corner)
&& !self.extras.minimap.image.is_empty()
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
@@ -266,32 +268,6 @@ impl Game<'_> {
};
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
}
if fps_on && visible(fps_corner) {
let reading = self.extras.fps.reading();
extras_hud::draw_fps(batch, atlas, &mut corners, fps_corner, reading, lows);
}
if keys_on && visible(keys_corner) {
let now = self.extras_now();
let cps = cps_on.then(|| {
let [l, r] = &mut self.extras.cps;
(l.per_second(now), r.per_second(now))
});
let held = |k| self.keys.contains(&k);
let label = |name| keybinds::display_name(self.settings.keys.get(name)).plain();
let k = Keystrokes {
moves: [
(held(KeyCode::KeyW), label("key.forward")),
(held(KeyCode::KeyA), label("key.left")),
(held(KeyCode::KeyS), label("key.back")),
(held(KeyCode::KeyD), label("key.right")),
],
jump: held(KeyCode::Space),
attack: self.extras.mouse[0],
use_: self.extras.mouse[1],
cps,
};
extras_hud::draw_keystrokes(batch, &self.gui.atlas, &mut corners, keys_corner, &k);
}
if mod_panels {
self.draw_mod_panels(batch, &mut corners);
}
@@ -45,7 +45,8 @@ pub(super) struct RulesScreen {
initial: GameRules,
/// IntegerRuleEntry's boxes, by rule.
numbers: Vec<(&'static str, EditBox)>,
scroll: f32,
/// RuleList's scrolling: the wheel and the held scrollbar.
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
}
@@ -120,7 +121,7 @@ impl RulesScreen {
values: rules.clone(),
initial: rules.clone(),
numbers,
scroll: 0.0,
scroll: ui::Scroll::default(),
confirm: false,
}
}
@@ -156,12 +157,17 @@ impl RulesScreen {
})
.collect()
}
fn max_scroll(rows: usize, h: f32) -> f32 {
(ROW * rows as f32 + 4.0 - (h - HEADER - FOOTER)).max(0.0)
}
/// Entry `i`'s top: 2 under the list's top, less the scroll.
fn row_top(&self, i: usize) -> f32 {
HEADER + 2.0 - self.scroll.trunc() + ROW * i as f32
HEADER + 2.0 - self.scroll.amount.trunc() + ROW * i as f32
}
/// The list's scrollbar column (AbstractSelectionList: 6 pixels right of
/// the entries) and the pixels it scrolls.
fn bar(w: f32) -> f32 {
row_left(w) + ROW_WIDTH + 6.0 + 2.0
}
fn content(&self) -> f32 {
ROW * self.rows().len() as f32 + 4.0
}
/// getEntryAtPosition: the row under the pointer, inside the list.
fn hovered(&self, rows: usize, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<usize> {
@@ -212,10 +218,28 @@ impl RulesScreen {
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let max = Self::max_scroll(self.rows().len(), h);
self.scroll = (self.scroll - delta * ROW / 2.0).clamp(0.0, max);
let content = self.content();
self.scroll
.wheel(delta, ROW / 2.0, HEADER, h - FOOTER, content);
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub(super) fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub(super) fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
if self.search.focused {
return Some((None, &mut self.search));
@@ -229,7 +253,7 @@ impl RulesScreen {
/// number sets its rule when valid (IntegerRuleEntry's responder).
fn edited(&mut self, rule: Option<&'static str>) {
let Some(name) = rule else {
self.scroll = 0.0;
self.scroll.amount = 0.0;
return;
};
let Some(r) = gamerules::rule(name) else {
@@ -286,6 +310,11 @@ impl RulesScreen {
if on(cx, cy, 150.0, 20.0) {
return self.close();
}
// A press on the scrollbar holds it and clicks nothing else.
if self.press_scrollbar(cursor, size) {
self.unfocus();
return None;
}
// A click moves the focus to the widget under it.
let (sx, sy) = search_at(w);
if on(sx, sy, SEARCH_WIDTH, 15.0) {
@@ -360,7 +389,8 @@ impl RulesScreen {
return;
}
let rows = self.rows();
self.scroll = self.scroll.clamp(0.0, Self::max_scroll(rows.len(), h));
let content = ROW * rows.len() as f32 + 4.0;
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
@@ -421,16 +451,14 @@ impl RulesScreen {
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
let content = ROW * rows.len() as f32 + 4.0;
let bar_x = left + ROW_WIDTH + 6.0 + 2.0;
ui::scrollbar(
batch,
atlas,
bar_x,
Self::bar(w),
HEADER,
h - FOOTER,
content,
self.scroll.trunc(),
self.scroll.amount.trunc(),
);
// The header: the title centred over the search box.
let title = Text::translate("editGamerule.title", &[]);
@@ -536,6 +564,29 @@ mod tests {
);
}
/// AbstractScrollArea: a press on the bar holds it, a drag moves the
/// list (to the ends past them), a release lets go.
#[test]
fn the_scroll_bar_can_be_held_dragged_and_let_go() {
let mut s = RulesScreen::new(&GameRules::default());
let (w, h) = (427.0, 240.0);
let (top, bottom, content) = (HEADER, h - FOOTER, s.content());
assert!(
content > bottom - top,
"the rules overflow a 240-high screen"
);
let bar = RulesScreen::bar(w);
assert!(!s.press_scrollbar((bar - 4.0, top + 5.0), (w, h)));
assert!(s.press_scrollbar((bar + 3.0, top + 5.0), (w, h)));
s.drag(top + 40.0, h);
assert!(s.scroll.amount > 0.0);
s.drag(bottom + 10.0, h);
assert_eq!(s.scroll.amount, content - (bottom - top));
s.release();
s.drag(top, h);
assert_eq!(s.scroll.amount, content - (bottom - top));
}
#[test]
fn edits_change_rules_and_bad_numbers_hold_done() {
let mut s = RulesScreen::new(&GameRules::default());
+54 -3
View File
@@ -143,7 +143,7 @@ pub(super) struct Serving {
flags: Arc<Flags>,
tasks: Receiver<Task>,
/// The window's profile.
owner: u128,
pub(super) owner: u128,
/// Open to LAN or closed from inside a player's command, while the hub
/// is out of its place: applied after the tick.
pending: Option<Publish>,
@@ -357,6 +357,16 @@ impl Game<'_> {
});
let _ = ready.send(Ok((pipe, self.level.name.clone())));
self.settle_spawn()?;
if let (Some(script), Some(output)) = (
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
) {
// The script owns the clock: connection setup and terrain loading
// cannot add wall-clock-dependent world ticks to oracle output.
let result = self.run_command_harness(script.as_ref(), output.as_ref());
let stopped = self.stop_integrated();
return result.and(stopped);
}
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
@@ -692,6 +702,48 @@ fn serve(
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
let mut server = server_game(res, pack, options, settings)?;
server.run_integrated(launch, flags, tasks, ready)
}
/// Component oracles supply their own synthetic levels and entity phases.
/// They still execute on a server Game, never the client's world.
pub(super) enum ComponentHarness {
Dispenser(String),
Sculk(String),
}
impl Game<'_> {
pub(super) fn run_component_harness(&self, fixture: ComponentHarness) -> Result<()> {
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
let mut options = self.options.clone();
options.execution_role = ExecutionRole::IntegratedServer;
options.dedicated = Some(Vec::new());
options.server_thread = false;
let result = std::thread::Builder::new()
.name("Server thread".into())
.spawn(move || {
let run = || -> Result<()> {
let mut server = server_game(&res, pack, options, settings)?;
match fixture {
ComponentHarness::Dispenser(spec) => server.dispense_fixture(&spec),
ComponentHarness::Sculk(spec) => server.sculk_fixture(&spec),
}
};
run().map_err(|e| e.to_string())
})?
.join()
.map_err(|_| "the component harness server panicked")?;
result.map_err(Into::into)
}
}
fn server_game(
res: &Arc<Resources>,
pack: PackResources,
options: Options,
settings: Settings,
) -> Result<Game<'_>> {
// It draws nothing: only the font, which books and chat measure with.
let atlas = HudAtlas::fonts(&options.resources)?;
let gui = UiResources {
@@ -702,8 +754,7 @@ fn serve(
}),
};
let audio = Audio::silent(&options.resources);
let mut server = Game::new(res, pack, gui, options, settings, audio, None)?;
server.run_integrated(launch, flags, tasks, ready)
Game::new(res, pack, gui, options, settings, audio, None)
}
#[cfg(test)]
+6 -3
View File
@@ -1136,9 +1136,9 @@ impl Game<'_> {
for e in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
// The acting guest does not predict the egg's teleport
// particles, nor bone meal's growth (BoneMealItem.useOn's
// levelEvent names no player).
let except = if matches!(*id, 2015 | 1505) {
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
None
} else {
self.lan_acting
@@ -1177,6 +1177,9 @@ impl Game<'_> {
for (id, event, at) in std::mem::take(&mut self.lan_entity_events) {
host.hub.entity_event(id, event, at);
}
for (kind, pos, data) in std::mem::take(&mut self.lan_global_events) {
host.hub.global_level_event(kind, pos, data);
}
self.lan_broadcast_effects(host);
if host.hub.guests.is_empty() {
host.mob_state.clear();
+79 -7
View File
@@ -38,6 +38,11 @@ pub(super) struct GuestPlay {
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
/// item a creative player drops from the creative screen, one less each tick.
creative_drops: u32,
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
attack_ticks: u32,
/// The main hand's item at the guest's last tick (`Game::attack_item`).
attack_item: Option<std::sync::Arc<str>>,
@@ -84,6 +89,7 @@ pub(super) struct GuestPlay {
equipped: Option<Vec<Option<ItemStack>>>,
/// The guest's cast fishing hook (`Player.fishing`).
pub(super) hook: Option<super::fishing::Hook>,
sculk: super::sculk::PlayerEvents,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -125,6 +131,8 @@ pub(super) struct GuestSwap {
using: Option<(usize, u32)>,
use_delay: u32,
pub(super) shield_cooldown: u32,
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
cooldowns: horizon_client::cooldowns::Cooldowns,
attack_ticks: u32,
attack_item: Option<std::sync::Arc<str>>,
swing: horizon_client::first_person::SwingState,
@@ -252,6 +260,7 @@ impl Game<'_> {
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet.
play.equipped = None;
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
// The guest's body.
@@ -259,6 +268,7 @@ impl Game<'_> {
host.play.insert(conn, play);
return None;
};
self.sculk.swap_player(&mut play.sculk);
// The guest's own CombatTracker: damage to them is not the host's.
// A new body starts with no record of the last one.
let guest_combat = if fresh {
@@ -328,6 +338,7 @@ impl Game<'_> {
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
let swing = std::mem::replace(&mut self.swing, play.swing);
@@ -412,6 +423,7 @@ impl Game<'_> {
using,
use_delay,
shield_cooldown,
cooldowns,
attack_ticks,
attack_item,
swing,
@@ -469,6 +481,7 @@ impl Game<'_> {
using,
use_delay,
shield_cooldown,
cooldowns,
attack_ticks,
attack_item,
swing,
@@ -492,6 +505,7 @@ impl Game<'_> {
projectiles,
modifiers,
} = swap;
self.sculk.swap_player(&mut play.sculk);
// The guest's damage record back into their slot, the host's in place.
let guest_combat = std::mem::replace(&mut self.combat, combat);
self.guest_combat.insert(conn, guest_combat);
@@ -508,6 +522,16 @@ impl Game<'_> {
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration,
}),
);
}
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
// An ender chest the guest opened: the menu shows their inventory
@@ -537,6 +561,7 @@ impl Game<'_> {
play.using = std::mem::replace(&mut self.using, using);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
play.swing = std::mem::replace(&mut self.swing, swing);
@@ -1064,6 +1089,35 @@ impl Game<'_> {
}
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
S::SetCreativeModeSlot(p) if p.slot < 0 => {
if !host.hub.guests[gi].creative() {
return;
}
let Some(stack) = self.item_from_network(host, &p.item) else {
return;
};
if stack.count
> self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack)
{
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
"Player {} was dropping items too fast in creative mode, ignoring.",
host.hub.guests[gi].name
);
return;
}
play.creative_drops += 20;
self.as_guest(host, conn, |g| g.player_drop(stack));
}
S::SetCreativeModeSlot(p) => {
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
@@ -1781,8 +1835,20 @@ impl Game<'_> {
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled).
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1808,14 +1874,13 @@ impl Game<'_> {
g.use_delay -= 1;
}
});
// ServerPlayerGameMode.handleBlockBreakAction: a creative
// player's START breaks the block once (destroyAndAck) and
// leaves nothing being destroyed; its client sends no STOP.
// Kept, the dig broke whatever was put there next, every
// destroy delay, for as long as the player broke nothing else.
// ServerPlayerGameMode.handleBlockBreakAction: creative and
// instant survival STARTs finish without a STOP from the client.
// tick_mining clears mining on completion; forget the START too,
// so a flower or crop planted here later is not broken again.
if digging.is_some()
&& host.hub.guests[gi].creative()
&& let Some(play) = host.play.get_mut(&conn)
&& (host.hub.guests[gi].creative() || play.mining.is_none())
{
play.digging = None;
}
@@ -2112,6 +2177,12 @@ impl Game<'_> {
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
if let MotionEvent::CauldronExtinguish { pos } = event {
game.cauldron_extinguish(*pos);
}
}
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
@@ -2153,6 +2224,7 @@ impl Game<'_> {
let Some((events, marked)) = events else {
return;
};
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
@@ -171,6 +171,19 @@ impl Game<'_> {
})
.collect()
}
/// The same guests' feet and whether they stand on the ground, for the
/// blocks that also ask that (`BigDripleafBlock.canEntityTilt`).
pub(super) fn lan_guest_feet(&self) -> Vec<([f64; 3], bool)> {
let Some(host) = self.lan.as_ref() else {
return Vec::new();
};
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| (g.position, g.on_ground))
.collect()
}
/// The guests a projectile can hit, under their entity ids.
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
@@ -161,12 +161,12 @@ impl Game<'_> {
+ (self.particles.random().next_float() - self.particles.random().next_float())
* 0.2)
* 0.7;
self.audio.play(
"minecraft:entity.generic.explode",
Some(p.center),
4.0,
pitch,
);
// The packet's explosionSound: a wind charge's burst, a bed's
// or anchor's blast, a modded sound.
let sound = self
.remote_sound_name(&p.sound)
.unwrap_or_else(|| "minecraft:entity.generic.explode".to_owned());
self.audio.play(&sound, Some(p.center), 4.0, pitch);
}
if let Some(options) = self.received_particle(&p.particle, p.center) {
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
+25 -1
View File
@@ -388,7 +388,10 @@ impl Restrictions {
1 => Some("chat_restriction.chat_disabled_by_options"),
_ => None,
};
let (chat, commands) = (chat_visibility == 0, chat_visibility < 2);
let abilities = horizon_client::chat::ChatAbilities::new(
horizon_client::chat::ChatVisibility::from_option(chat_visibility),
);
let (chat, commands) = (abilities.send_messages, abilities.send_commands);
// The body's widgets: text blocks (lines, top) and the action's top.
let mut blocks = Vec::new();
let mut action = None;
@@ -802,6 +805,27 @@ impl Game<'_> {
}
}
/// The pointer moved while a list's scrollbar is held: the game rules'
/// list follows it (AbstractSelectionList.mouseDragged).
pub(super) fn world_options_drag(&mut self) {
let h = self.gui_size().1;
if let Some(o) = self.world_options.as_mut()
&& o.view == View::GameRules
&& let Some(rules) = o.rules.as_mut()
{
rules.drag(self.cursor.1, h);
}
}
/// The button came up: a held scrollbar lets go (onRelease).
pub(super) fn world_options_release(&mut self) {
if let Some(o) = self.world_options.as_mut()
&& let Some(rules) = o.rules.as_mut()
{
rules.release();
}
}
pub(super) fn world_options_click(&mut self) {
let Some(mut o) = self.world_options.take() else {
return;
+2
View File
@@ -79,6 +79,8 @@ impl Game<'_> {
if horizon_client::mobs::leash::remove(m).is_none() {
return;
}
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
if sound {
self.lead_sound("break", at);
@@ -101,6 +101,39 @@ impl Game<'_> {
}
}
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
pub(super) fn global_level_event(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.global_level_event_sound(id, pos);
if self.audio.capture.is_some() {
self.lan_global_events.push((id, pos, data));
}
}
/// `LevelEventHandler.globalLevelEvent`: the sound two blocks from the
/// camera towards the event, so it is heard from anywhere.
pub(super) fn global_level_event_sound(&mut self, id: i32, pos: [i32; 3]) {
let (sound, volume) = match id {
1023 => ("minecraft:entity.wither.spawn", 1.0),
1028 => ("minecraft:entity.ender_dragon.death", 5.0),
1038 => ("minecraft:block.end_portal.spawn", 1.0),
_ => return,
};
let at = self.eye_position().map(|eye| {
let d = [
f64::from(pos[0]) + 0.5 - eye[0],
f64::from(pos[1]) + 0.5 - eye[1],
f64::from(pos[2]) + 0.5 - eye[2],
];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if length < 1.0e-4 {
return eye;
}
[0, 1, 2].map(|k| eye[k] + d[k] / length * 2.0)
});
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+25 -6
View File
@@ -21,7 +21,7 @@ use super::{
use horizon_client::{
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership,
ticket_levels::ChunkTickets, world::World,
ticket_levels::ChunkTickets, vanillagen::VanillaTerrain, world::World,
};
use horizon_world::level::{LevelEvent, Ticks};
use std::{
@@ -39,6 +39,7 @@ const IDLE_TICKS: u32 = 300;
pub(super) struct Parked {
world: World,
streaming: Streaming,
locator: Option<Arc<VanillaTerrain>>,
save: Arc<SaveDir>,
block_ticks: Ticks,
tick_chunks: TickChunkMembership,
@@ -109,13 +110,21 @@ pub(super) struct Levels {
impl Game<'_> {
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
fn open_level(&self, dim: Dim) -> Result<Parked> {
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
let (streaming, save, locator) = super::world_loading::make_streaming(
&self.res,
&self.pack,
&self.options,
&self.settings,
&self.world_root,
dim,
self.level.seed,
)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.fast_lava = dim == Dim::Nether;
Ok(Parked {
world,
streaming,
locator,
save,
block_ticks: Ticks::new(self.level.seed as i32),
tick_chunks: Default::default(),
@@ -163,6 +172,9 @@ impl Game<'_> {
use std::mem::swap;
swap(&mut self.world, &mut p.world);
swap(&mut self.streaming, &mut p.streaming);
let mut locator = self.end.locator();
swap(&mut locator, &mut p.locator);
self.end.set_locator(locator);
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
@@ -365,7 +377,12 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
std::thread::sleep(*next - now);
// A `/pregen` task keeps the workers fed until the next tick.
self.pump_pregen(*next);
let now = Instant::now();
if *next > now {
std::thread::sleep(*next - now);
}
} else if now - *next > Duration::from_secs(2) {
*next = now;
}
@@ -373,7 +390,7 @@ impl Game<'_> {
/// The levels this server tick runs, in `MinecraftServer.levels` order
/// (the Overworld, the Nether, the End): every level with a player in
/// it, or the Overworld while nobody is online.
/// it or a `/pregen` task, or the Overworld while nobody is online.
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
let guests = self
.lan
@@ -381,7 +398,9 @@ impl Game<'_> {
.map_or(&[][..], |h| h.hub.guests.as_slice());
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
.into_iter()
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
.filter(|d| {
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
})
.collect();
if dims.is_empty() {
dims.push(Dim::Overworld);
+6 -5
View File
@@ -923,15 +923,16 @@ impl Game<'_> {
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
}
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
let title = self
.remote_menu_title()
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
let title = &self.remote_menu_title().unwrap_or_else(|| {
horizon_client::text::Text::literal(self.container_title(self.translate(key, fallback)))
.resolve_legacy()
});
let title_x = if screen == Screen::Hopper {
8.0
} else {
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0
(176.0 - atlas.font.width_text(title)) / 2.0
};
batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false);
batch.text_styled(atlas, title, ox + title_x, oy + 6.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false);
true
@@ -33,7 +33,7 @@ fn recipe_matches(condition: &Value, id: &str) -> bool {
impl Game<'_> {
/// `Level.setBlock` of one boolean property of the block at `pos`.
fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
pub(crate) fn set_bool_prop(&mut self, pos: Pos, key: &str, value: bool, flags: i32) {
let registry = self.pack.assets.registry;
let old = self.world.block(pos.0, pos.1, pos.2);
let Some(mut state) = registry.state(old).cloned() else {
@@ -1463,6 +1463,7 @@ impl Game<'_> {
lid: &lid,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
};
self.res
+181
View File
@@ -0,0 +1,181 @@
//! Capability `input` (docs/mods.md): the use button on items a mod claims,
//! the mod's own keys and the mouse wheel; and `render`'s zoom. With no mod
//! claiming items, keys or the wheel, each hook here returns after one
//! check.
use super::Game;
use winit::{event::MouseButton, keyboard::KeyCode};
#[cfg(feature = "mods")]
use {super::Screen, horizon_mods::HeldItem};
/// A claimed item's use while the button is down.
#[cfg(feature = "mods")]
pub(super) struct ModUse {
item: HeldItem,
/// `Game::ticks` when it started.
since: u64,
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn mod_use(&mut self, _button: MouseButton, _pressed: bool) -> bool {
false
}
pub(super) fn mod_key(&mut self, _key: KeyCode, _pressed: bool, _repeat: bool) {}
pub(super) fn mod_mouse_key(&mut self, _button: MouseButton, _pressed: bool) {}
pub(super) fn mod_wheel(&mut self, _steps: f32) -> bool {
false
}
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
None
}
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModZoom {
pub(super) factor: f32,
pub(super) smooth: bool,
pub(super) turn: f32,
}
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModMinimap {
pub(super) corner: horizon_client::extras::Corner,
pub(super) size: f32,
pub(super) blocks_per_pixel: f32,
pub(super) rotate: bool,
pub(super) round: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
pub(super) fn mod_held(&self) -> Option<HeldItem> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
Some(HeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: self.selected as u32,
})
}
/// The use button while playing: true when a mod claimed the held item,
/// so the game does nothing else with it (no placing, eating, swing).
pub(super) fn mod_use(&mut self, button: MouseButton, pressed: bool) -> bool {
if button != MouseButton::Right || !self.mods.mods.claims_any() {
return false;
}
if !pressed {
return self.stop_mod_use();
}
let Some(item) = self.mod_held().filter(|h| self.mods.mods.claims(&h.id)) else {
return false;
};
self.mods.mods.use_start(&item);
self.mods.using = Some(ModUse {
item,
since: self.ticks,
});
true
}
/// Ends a claimed use: the button came up, the player switched slots,
/// or a screen opened. False when none was going.
pub(super) fn stop_mod_use(&mut self) -> bool {
let Some(u) = self.mods.using.take() else {
return false;
};
let ticks = self.ticks.saturating_sub(u.since).min(u64::from(u32::MAX)) as u32;
self.mods.mods.use_stop(&u.item, ticks);
true
}
/// Once a tick: a claimed use stops when its item leaves the hand or a
/// screen opens, as vanilla's `releaseUsingItem`.
pub(super) fn tick_mod_use(&mut self) {
let Some(u) = &self.mods.using else {
return;
};
let still = self
.mod_held()
.is_some_and(|h| h.id == u.item.id && h.slot == u.item.slot);
if !still || self.screen != Screen::Playing {
self.stop_mod_use();
}
}
/// A key went down or up: a mod's registered key while playing (no
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
/// key still runs.
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if repeat || !self.mods.mods.has_keys() {
return;
}
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
return;
}
if let Some(name) = horizon_client::keybinds::key_name(key) {
self.mods.mods.key(name, pressed);
}
}
/// A mouse button while playing: a mod's key bound to it
/// (`key.mouse.4`...), as `mod_key`.
pub(super) fn mod_mouse_key(&mut self, button: MouseButton, pressed: bool) {
if self.mods.mods.has_keys() {
let name = horizon_client::keybinds::mouse_name(button);
self.mods.mods.key(&name, pressed);
}
}
/// Wheel steps while playing: true when a mod holds the wheel, so the
/// hotbar doesn't move.
pub(super) fn mod_wheel(&mut self, steps: f32) -> bool {
self.mods.mods.wheel(steps)
}
/// Playing, with nothing over the world: mods' keys and zoom apply.
fn mod_playing(&self) -> bool {
self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none()
}
/// Once a frame, before the mods run: a screen that opened lets their
/// held keys up.
pub(super) fn mod_input_frame(&mut self) {
let playing = self.mod_playing();
if self.mods.playing && !playing {
self.mods.mods.release_keys();
}
self.mods.playing = playing;
}
/// The strongest zoom a mod asks for, while playing; the spyglass's
/// own view wins.
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
if !self.mod_playing() || self.scoping() {
return None;
}
let z = self.mods.mods.zoom()?;
Some(ModZoom {
factor: z.factor,
smooth: z.smooth,
turn: z.turn,
})
}
/// The minimap the first mod showing one asks for.
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
use horizon_client::extras::Corner;
use horizon_mods::RenderCorner;
let m = self.mods.mods.minimap()?;
Some(ModMinimap {
corner: match m.corner {
RenderCorner::TopLeft => Corner::TopLeft,
RenderCorner::TopRight => Corner::TopRight,
RenderCorner::BottomLeft => Corner::BottomLeft,
RenderCorner::BottomRight => Corner::BottomRight,
},
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
}
}
@@ -0,0 +1,184 @@
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
//! never supply shader code. With no mod drawing, each hook here returns
//! after one check.
use super::Game;
use horizon_client::hud::HudBatch;
#[cfg(feature = "mods")]
use {
horizon_client::{hud::rgba, items::ItemStack, text::Text},
horizon_mods::Shape,
std::time::Instant,
};
/// The camera's jolts from `render.shake`, while any is fading.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Shake {
jolts: Vec<Jolt>,
}
#[cfg(feature = "mods")]
struct Jolt {
start: Instant,
strength: f32,
seconds: f32,
}
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
#[cfg(feature = "mods")]
fn argb(color: u32) -> u32 {
let [a, r, g, b] = color.to_be_bytes();
rgba(r, g, b, a)
}
/// The view's turn at full strength, in degrees: yaw, then pitch.
#[cfg(feature = "mods")]
const SHAKE_DEGREES: (f32, f32) = (3.0, 2.0);
#[cfg(feature = "mods")]
impl Shake {
pub(super) fn add(&mut self, strength: f32, seconds: f32) {
self.jolts.push(Jolt {
start: Instant::now(),
strength,
seconds,
});
}
/// How far the view turns now (yaw, pitch degrees), `scale` being the
/// player's Distortion Effects; fades jolts out and forgets ended ones.
fn offset(&mut self, now: Instant, scale: f32) -> (f32, f32) {
self.jolts
.retain(|j| now.duration_since(j.start).as_secs_f32() < j.seconds);
let mut strength = 0.0;
for j in &self.jolts {
let left = 1.0 - now.duration_since(j.start).as_secs_f32() / j.seconds;
strength += j.strength * left * left;
}
let strength = strength.min(1.0) * scale.clamp(0.0, 1.0);
if strength <= 0.0 {
return (0.0, 0.0);
}
// Smooth noise: sines at unrelated rates, so it never repeats visibly.
let t = self
.jolts
.first()
.map_or(0.0, |j| now.duration_since(j.start).as_secs_f32());
let wave = |a: f32, b: f32, c: f32| {
((t * a).sin() * 0.5 + (t * b).sin() * 0.3 + (t * c).sin() * 0.2).clamp(-1.0, 1.0)
};
(
strength * SHAKE_DEGREES.0 * wave(23.0, 37.3, 51.7),
strength * SHAKE_DEGREES.1 * wave(29.1, 43.9, 61.3),
)
}
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_shapes(&self, _batch: &mut HudBatch) {}
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
(yaw, pitch)
}
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The running mods' HUD shapes, in order, over the HUD.
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
for shape in self.mods.mods.hud_shapes() {
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
}
}
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
let atlas = &self.gui.atlas;
for shape in shapes {
match shape {
Shape::Fill(f) => {
let r = f.rect;
batch.fill(atlas, ox + r.x, oy + r.y, r.w, r.h, argb(f.color));
}
Shape::Quad(q) => {
let (a, b, c, d) = q.corners;
let corners = [a, b, c, d].map(|p| [ox + p.x, oy + p.y]);
batch.fill_quad(atlas, corners, argb(q.color));
}
Shape::Sprite(s) => {
let r = s.rect;
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
let name = format!("gui/sprites/{name}");
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
}
Shape::Item(i) => {
if self.res.items().get(&i.item).is_none() {
continue;
}
let start = batch.vertices.len();
let stack = ItemStack::new(i.item.as_str(), 1);
let (x, y) = (ox + i.at.x, oy + i.at.y);
self.draw_item_model(batch, &stack, x, y);
batch.scale_since(start, x, y, i.scale, i.scale);
}
Shape::Text(t) => {
let text = Text::literal(t.text.clone());
let centre = if t.centered {
(HudBatch::text_width(atlas, &t.text) / 2.0).floor()
} else {
0.0
};
let x = ox + t.at.x - centre;
batch.text_styled(
atlas,
&text,
x,
oy + t.at.y,
argb(t.color | 0xFF00_0000),
t.shadow,
);
}
}
}
}
/// The view's yaw and pitch after the mods' camera shakes; unchanged
/// while none is fading.
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
if self.mods.shake.jolts.is_empty() {
return (yaw, pitch);
}
let scale = self.settings.file.double("screenEffectScale") as f32;
let (dy, dp) = self.mods.shake.offset(Instant::now(), scale);
(yaw + dy, (pitch + dp).clamp(-90.0, 90.0))
}
}
#[cfg(all(test, feature = "mods"))]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn a_jolt_fades_out_and_distortion_scales_it() {
let mut shake = Shake::default();
shake.add(1.0, 2.0);
let start = shake.jolts[0].start;
let at = |s: f32| start + Duration::from_secs_f32(s);
let size = |(y, p): (f32, f32)| y.abs() + p.abs();
// Off with Distortion Effects at 0, inside the limits otherwise.
assert_eq!(shake.offset(at(0.3), 0.0), (0.0, 0.0));
let (y, p) = shake.offset(at(0.3), 1.0);
assert!(y.abs() <= SHAKE_DEGREES.0 && p.abs() <= SHAKE_DEGREES.1);
// Fading: the largest turn over a window shrinks.
let mut peak = |from: f32| {
(0..50)
.map(|i| size(shake.offset(at(from + i as f32 * 0.002), 1.0)))
.fold(0.0, f32::max)
};
let early = peak(0.1);
let late = peak(1.6);
assert!(late < early, "{late} !< {early}");
// Gone once it ends.
assert_eq!(shake.offset(at(2.1), 1.0), (0.0, 0.0));
assert!(shake.jolts.is_empty());
}
}
+313 -59
View File
@@ -21,19 +21,27 @@ use {
extras::Corner,
extras_hud,
items::ItemStack,
keybinds,
mod_list::Installed,
options_ui::Page,
text::{self, Text},
ui,
},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
};
#[derive(Default)]
pub(super) struct ModsState {
#[cfg(feature = "mods")]
mods: Mods,
pub(super) mods: Mods,
/// Capability `input`: a claimed item's use while the button is down.
#[cfg(feature = "mods")]
pub(super) using: Option<super::mod_input::ModUse>,
/// Capability `render`'s camera shakes.
#[cfg(feature = "mods")]
pub(super) shake: super::mod_render::Shake,
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
pub(super) dev: Vec<PathBuf>,
/// When dev mods were last checked for a rebuild.
@@ -53,8 +61,38 @@ pub(super) struct ModsState {
/// A `ui` mod's screen or settings page while it is up.
#[cfg(feature = "mods")]
screen: Option<ModScreen>,
/// The HUD font's advances for `render.text-width`, made once.
#[cfg(feature = "mods")]
font_widths: std::sync::Arc<[f32]>,
/// Extras that are official mods now, being installed.
#[cfg(feature = "mods")]
pub(super) moved: super::moved_extras::MovedState,
/// Playing at the last frame, for `mod_input_frame`.
#[cfg(feature = "mods")]
pub(super) playing: bool,
}
/// The mappings `input.mapping` answers for, with the default key their
/// binding is translated to (none: a mouse button, attack or use).
#[cfg(feature = "mods")]
const MAPPINGS: [(&str, Option<winit::keyboard::KeyCode>); 9] = {
use winit::keyboard::KeyCode as K;
[
("key.forward", Some(K::KeyW)),
("key.left", Some(K::KeyA)),
("key.back", Some(K::KeyS)),
("key.right", Some(K::KeyD)),
("key.jump", Some(K::Space)),
("key.sneak", Some(K::ShiftLeft)),
("key.sprint", Some(K::ControlLeft)),
("key.attack", None),
("key.use", None),
]
};
/// Characters `render.text-width` knows exactly: U+0000 to U+024F.
#[cfg(feature = "mods")]
const FONT_CHARS: u32 = 0x250;
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
#[cfg(feature = "mods")]
@@ -67,6 +105,8 @@ struct ModScreen {
under: Screen,
/// One scroll per `list` widget, in widget order.
scrolls: Vec<ui::Scroll>,
/// A key row waiting for the key to bind: the mod key's name.
binding: Option<String>,
}
impl ModsState {
/// The chat's command tree: vanilla's, plus the running mods' commands.
@@ -93,10 +133,10 @@ impl ModsState {
}
false
}
/// Whether a mod draws a HUD panel.
/// Whether a mod draws a HUD panel or a corner panel.
pub(super) fn has_panels(&self) -> bool {
#[cfg(feature = "mods")]
if self.mods.panels().next().is_some() {
if self.mods.panels().next().is_some() || self.mods.corner_panels().next().is_some() {
return true;
}
false
@@ -206,7 +246,10 @@ impl Game<'_> {
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
pub(super) fn mod_screen_click(&mut self) {}
pub(super) fn mod_screen_click(&mut self, _button: winit::event::MouseButton) {}
pub(super) fn mod_screen_key(&mut self, _key: winit::keyboard::KeyCode) -> bool {
false
}
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
}
@@ -229,6 +272,8 @@ impl Game<'_> {
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load(&paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
// Entries were replaced wholesale: a screen's owner index is stale.
self.mods.screen = None;
self.list_mods();
@@ -322,8 +367,8 @@ impl Game<'_> {
self.start_mods();
}
}
/// What mods read this tick.
fn mod_snapshot(&self) -> Snapshot {
/// What mods read this tick (or frame, for mods with a `frame` hook).
fn mod_snapshot(&mut self) -> Snapshot {
let player = self.player.as_ref().map(|p| Player {
x: p.position.x,
y: p.position.y,
@@ -338,7 +383,36 @@ impl Game<'_> {
game_time: self.world.game_time.max(0) as u64,
day_time: self.world.day_time.max(0) as u64,
});
Snapshot { player, clock }
let (cps, mouse) = self.mouse_reading();
// The movement and click mappings, as the keystroke overlay reads them:
// keys by the default key their binding is translated to.
let mappings = MAPPINGS
.iter()
.map(|&(name, key)| {
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
let down = match key {
Some(k) => self.keys.contains(&k),
None => mouse[usize::from(name == "key.use")],
};
(name, label, down)
})
.collect();
if self.mods.font_widths.is_empty() {
let atlas = &self.gui.atlas;
self.mods.font_widths = (0..FONT_CHARS)
.map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1))
.collect();
}
Snapshot {
player,
clock,
gui_size: self.gui_size(),
held: self.mod_held(),
frame: self.frame_reading(),
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
}
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
@@ -350,6 +424,7 @@ impl Game<'_> {
if !self.mods.mods.is_running() {
return;
}
self.tick_mod_use();
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
self.mods.mods.tick(self.ticks);
@@ -365,6 +440,8 @@ impl Game<'_> {
{
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
@@ -373,6 +450,11 @@ impl Game<'_> {
if !self.mods.mods.is_running() {
return;
}
self.mod_input_frame();
if self.mods.mods.wants_frames() {
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
}
self.mods.mods.frame(elapsed.as_secs_f32());
self.show_mod_output();
}
@@ -430,6 +512,10 @@ impl Game<'_> {
self.open_mod_screen(owner, false);
continue;
}
Output::Shake { strength, seconds } => {
self.mods.shake.add(strength, seconds);
continue;
}
Output::CloseScreen { owner } => {
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
self.close_mod_screen(false);
@@ -438,10 +524,16 @@ impl Game<'_> {
}
};
// Not through system_message: a mod must not hear its own lines.
// Written by this client, so the Chat option doesn't hide it.
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Client,
self.ticks,
focused,
);
}
// A mod may set or clear its settings page in any call, not only
// while loading: its row's Settings button follows.
@@ -470,39 +562,118 @@ impl Game<'_> {
.collect();
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
}
// Capability `render`'s corner panels, placed the same way.
for panel in self.mods.mods.corner_panels() {
let corner = match panel.corner {
horizon_mods::RenderCorner::TopLeft => Corner::TopLeft,
horizon_mods::RenderCorner::TopRight => Corner::TopRight,
horizon_mods::RenderCorner::BottomLeft => Corner::BottomLeft,
horizon_mods::RenderCorner::BottomRight => Corner::BottomRight,
};
if self.debug && corner == Corner::TopLeft {
continue;
}
let at = corners.place(corner, panel.width, panel.height);
self.draw_shapes(batch, &panel.shapes, at);
}
}
/// A `ui` screen's title and widgets as the mod last pushed them; None
/// when the surface is gone (the mod stopped or closed it).
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
let ms = self.mods.screen.as_ref()?;
/// when the surface is gone (the mod stopped or closed it). Borrows
/// `mods` only, so `self.mods.screen` may stay borrowed for the scrolls.
fn mod_ui_view<'a>(mods: &'a Mods, ms: &ModScreen) -> Option<(&'a str, &'a [UiWidget])> {
if ms.settings {
let widgets = self.mods.mods.settings_widgets(ms.owner);
if widgets.is_empty() {
let widgets = mods.settings_widgets(ms.owner);
if widgets.is_empty() && mods.keys_of(ms.owner).is_empty() {
return None;
}
let name = self
.mods
.mods
let name = mods
.entries()
.get(ms.owner)
.and_then(|e| e.manifest.as_ref())
.map_or("Mod", |m| m.name.as_str());
Some((name.to_string(), widgets.to_vec()))
Some((name, widgets))
} else {
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
Some((s.title.clone(), s.widgets.clone()))
let s: &UiScreen = mods.screen(ms.owner)?;
Some((s.title.as_str(), s.widgets.as_slice()))
}
}
/// The keys a settings page lists first: the mod's own (`input`).
fn mod_page_keys<'a>(mods: &'a Mods, ms: &ModScreen) -> &'a [ModKey] {
if ms.settings {
mods.keys_of(ms.owner)
} else {
&[]
}
}
/// A key row's label as Key Binds shows one, "Zoom: Z", marked
/// "> Zoom: Z <" in yellow while it waits for the new key.
fn mod_key_label(k: &ModKey, waiting: bool) -> Text {
let value = format!("{}: {}", k.label, keybinds::display_name(&k.key).plain());
if waiting {
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
} else {
Text::literal(value)
}
}
/// The player bound the key row that waited to `key`: the mod's key
/// follows it now and at the next start (`horizon-mods.txt`).
fn bind_mod_key(&mut self, key: String) {
let Some(ms) = self.mods.screen.as_mut() else {
return;
};
let Some(name) = ms.binding.take() else {
return;
};
let owner = ms.owner;
if !self.mods.mods.rebind(owner, &name, &key) {
return;
}
let Some(id) = self
.mods
.mods
.entries()
.get(owner)
.and_then(|e| e.manifest.as_ref())
.map(|m| m.id.clone())
else {
return;
};
self.settings
.mods
.keys
.entry(id)
.or_default()
.insert(name, key);
self.settings.save(&Self::options_path());
}
/// A key while a mod screen is up: true when a key row waited for
/// it (Key Binds: Escape binds none).
pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool {
if self
.mods
.screen
.as_ref()
.is_none_or(|ms| ms.binding.is_none())
{
return false;
}
let value = match key {
KeyCode::Escape => None,
k => keybinds::key_name(k),
};
self.bind_mod_key(value.unwrap_or("key.keyboard.unknown").to_owned());
true
}
/// The layout the mod screen's widgets get at `h`: (widget, top,
/// height), the column being `COLUMN` wide down the middle.
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
fn mod_ui_layout(h: f32, top: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
let mut out = Vec::with_capacity(widgets.len());
let mut y = MOD_UI_TOP;
let mut y = top;
for w in widgets {
let wh = match w {
UiWidget::Text(_) => 12.0,
UiWidget::Item(_) => 20.0,
UiWidget::Item(_) | UiWidget::Button(_) => 20.0,
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
};
// Nothing may cover Done; a crowded mod loses what won't fit.
@@ -522,7 +693,8 @@ impl Game<'_> {
}
if settings {
self.mods.mods.ui_settings_opened(owner);
if self.mods.mods.settings_widgets(owner).is_empty() {
let keys = self.mods.mods.keys_of(owner);
if self.mods.mods.settings_widgets(owner).is_empty() && keys.is_empty() {
self.show_mod_output();
return;
}
@@ -532,6 +704,7 @@ impl Game<'_> {
settings,
under: self.screen,
scrolls: Vec::new(),
binding: None,
});
self.screen = Screen::Mod;
if self.player.is_some() {
@@ -561,13 +734,19 @@ impl Game<'_> {
/// The mod's screen or settings page: its title over widgets stacked
/// down the middle, a Done button, Esc to leave (vanilla's menus).
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
if self.mods.screen.is_none() {
let Some(ms) = self.mods.screen.as_mut() else {
// The screen was dropped while it was up: back to the world,
// mouse held again like any return to Playing.
self.screen = Screen::Playing;
if self.player.is_some() {
self.capture(true);
}
return;
}
let Some((title, widgets)) = self.mod_ui_view() else {
self.mods.screen = None;
self.screen = Screen::Playing;
};
let Some((title, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
// The mod's surface is gone (it stopped or cleared it): leave
// the screen like Done did, without `closed` — the mod ended it.
self.close_mod_screen(false);
return;
};
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
@@ -587,10 +766,45 @@ impl Game<'_> {
);
let cx = (batch.width / 2.0).floor();
let x = cx - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(batch.height, &widgets);
// A settings page starts with the mod's keys, as Key Binds rows.
let keys = Self::mod_page_keys(&self.mods.mods, ms);
for (i, k) in keys.iter().enumerate() {
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0);
let waiting = ms.binding.as_deref() == Some(k.name.as_str());
let label = Self::mod_key_label(k, waiting);
ui::button(
batch,
atlas,
x,
wy,
MOD_UI_COLUMN,
20.0,
&label,
true,
hover,
);
}
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(batch.height, top, widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
match w {
UiWidget::Button(b) => {
let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0);
let label = Text::literal(b.label.clone());
ui::button(
batch,
atlas,
x,
wy,
MOD_UI_COLUMN,
20.0,
&label,
true,
hover,
);
}
UiWidget::Text(t) => {
let color = if t.color == 0 {
u32::MAX
@@ -628,11 +842,8 @@ impl Game<'_> {
);
}
UiWidget::List(l) => {
let scroll = {
let ms = self.mods.screen.as_mut().expect("checked");
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
ms.scrolls[list_i]
};
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
let scroll = ms.scrolls[list_i];
list_i += 1;
let rows_top = wy + 4.0;
let rows_bottom = wy + wh - 4.0;
@@ -695,7 +906,20 @@ impl Game<'_> {
}
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
/// `ui-events::select`.
pub(super) fn mod_screen_click(&mut self) {
pub(super) fn mod_screen_click(&mut self, button: MouseButton) {
// Key Binds: a mouse button pressed while waiting binds it.
if self
.mods
.screen
.as_ref()
.is_some_and(|ms| ms.binding.is_some())
{
self.bind_mod_key(keybinds::mouse_name(button));
return;
}
if button != MouseButton::Left {
return;
}
let (w, h) = self.gui_size();
let cursor = self.cursor;
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
@@ -703,53 +927,80 @@ impl Game<'_> {
self.close_mod_screen(true);
return;
}
let Some((_, widgets)) = self.mod_ui_view() else {
let Some(ms) = self.mods.screen.as_ref() else {
return;
};
let Some((_, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
return;
};
let owner = ms.owner;
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &widgets);
// A key row waits for the key to bind.
let keys = Self::mod_page_keys(&self.mods.mods, ms);
let picked = (0..keys.len()).find(|&i| {
let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW;
ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0)
});
if let Some(i) = picked {
let name = keys[i].name.clone();
self.audio.play_button_click();
if let Some(ms) = self.mods.screen.as_mut() {
ms.binding = Some(name);
}
return;
}
let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(h, top, widgets);
let mut list_i = 0usize;
// The `ui_select` call needs `&mut`: find the pick first.
let mut hit = None;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
// A button is `select(id, 0)`.
if let UiWidget::Button(b) = w
&& ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0)
{
self.audio.play_button_click();
let owner = self.mods.screen.as_ref().map_or(0, |ms| ms.owner);
self.mods.mods.ui_select(owner, b.id, 0);
self.show_mod_output();
return;
}
let UiWidget::List(l) = w else { continue };
let scroll = self
.mods
.screen
.as_ref()
.and_then(|ms| ms.scrolls.get(list_i).copied())
.unwrap_or_default();
let scroll = ms.scrolls.get(list_i).copied().unwrap_or_default();
list_i += 1;
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
&& row >= 0
&& (row as usize) < l.rows.len()
{
self.audio.play_button_click();
self.mods.mods.ui_select(
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
l.id,
row as u32,
);
self.show_mod_output();
return;
hit = Some((l.id, row as u32));
break;
}
}
if let Some((id, row)) = hit {
self.audio.play_button_click();
self.mods.mods.ui_select(owner, id, row);
self.show_mod_output();
}
}
/// The wheel over a `list` scrolls it.
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
let (w, h) = self.gui_size();
let cursor = self.cursor;
let Some((_, widgets)) = self.mod_ui_view() else {
let Some(ms) = self.mods.screen.as_mut() else {
return;
};
let Some((_, widgets)) = Self::mod_ui_view(&self.mods.mods, ms) else {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &widgets);
let top =
MOD_UI_TOP + Self::mod_page_keys(&self.mods.mods, ms).len() as f32 * MOD_UI_KEY_ROW;
let layout = Self::mod_ui_layout(h, top, widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
let UiWidget::List(l) = w else { continue };
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
let Some(ms) = self.mods.screen.as_mut() else {
return;
};
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
if over {
let content = l.rows.len() as f32 * 12.0;
@@ -765,3 +1016,6 @@ impl Game<'_> {
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
const MOD_UI_COLUMN: f32 = 200.0;
/// A key row's height with its gap, as the widgets below it space out.
#[cfg(feature = "mods")]
const MOD_UI_KEY_ROW: f32 = 26.0;
@@ -0,0 +1,122 @@
//! Extras that are official mods now (`horizon_client::moved_extras`): on
//! the first start after the update, a player who had one on gets the mod
//! installed from Horizon's registry, turned on, with their settings, and
//! one line in the chat saying so. The download runs on its own thread; if
//! it fails (offline), the game tries again at the next start.
#[cfg(not(feature = "mods"))]
impl super::Game<'_> {
pub(super) fn start_moved_extras(&mut self) {}
pub(super) fn poll_moved_extras(&mut self) {}
}
#[cfg(feature = "mods")]
mod with_mods {
use super::super::Game;
use horizon_client::{
moved_extras::{self, Moved},
text::{self, Text},
};
use std::sync::mpsc::{Receiver, channel};
type Done = Vec<(&'static Moved, Result<(), String>)>;
#[derive(Default)]
pub(in crate::game) struct MovedState {
/// The installs under way.
running: Option<Receiver<Done>>,
/// Lines for the player, shown once a world is open.
notices: Vec<Text>,
}
impl Game<'_> {
/// At start: remembers the moved extras that were off, and installs
/// the ones that were on, in the background.
pub(in crate::game) fn start_moved_extras(&mut self) {
let off = moved_extras::nothing_to_do(&self.settings.extras);
let pending = moved_extras::pending(&self.settings.extras);
if off.is_empty() && pending.is_empty() {
return;
}
if !off.is_empty() {
let e = &mut self.settings.extras;
e.moved.extend(off.iter().map(|m| m.id.to_owned()));
self.settings.save(&Self::options_path());
}
if pending.is_empty() {
return;
}
let extras = self.settings.extras.clone();
let dir = Self::options_path()
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
let (tx, rx) = channel();
self.mods.moved.running = Some(rx);
let spawned = std::thread::Builder::new()
.name("Moved extras".into())
.spawn(move || {
let done = match moved_extras::fetch_index() {
Ok(index) => pending
.into_iter()
.map(|m| (m, moved_extras::install(&index, m, &extras, &dir)))
.collect(),
Err(e) => pending.into_iter().map(|m| (m, Err(e.clone()))).collect(),
};
let _ = tx.send(done);
});
if spawned.is_err() {
self.mods.moved.running = None;
}
}
/// Once a frame: the installs that finished turn their mods on;
/// their lines wait for a world.
pub(in crate::game) fn poll_moved_extras(&mut self) {
if let Some(rx) = &self.mods.moved.running
&& let Ok(done) = rx.try_recv()
{
self.mods.moved.running = None;
let mut installed = false;
for (m, result) in done {
match result {
Ok(()) => {
self.settings.extras.moved.push(m.id.to_owned());
self.settings.mods.enabled.insert(m.id.to_owned());
// The install wrote the key to horizon-mods.txt;
// the settings save below must keep it.
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(&self.settings.extras) {
keys.entry(name.to_owned()).or_insert(key);
}
self.settings.mods.keys.retain(|_, k| !k.is_empty());
self.mods.moved.notices.push(Text::literal(m.notice()));
installed = true;
}
Err(e) => {
log::warn!("Horizon: {} not installed, next start retries: {e}", m.id);
self.mods.moved.notices.push(
Text::literal(format!(
"{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start",
m.name
))
.with_color(text::GRAY),
);
}
}
}
if installed {
self.settings.save(&Self::options_path());
self.start_mods();
}
}
if !self.mods.moved.notices.is_empty() && self.player.is_some() && self.title.is_none()
{
for line in std::mem::take(&mut self.mods.moved.notices) {
self.system_message(line);
}
}
}
}
}
#[cfg(feature = "mods")]
pub(super) use with_mods::MovedState;
+40
View File
@@ -240,6 +240,42 @@ impl Game<'_> {
discrete: false,
});
}
// ItemFrameRenderer.shouldShowName/getNameTag: only the targeted
// frame's custom-named item, with the HUD visible (also glow frames).
if !self.ui.hide_gui
&& let Some((i, distance)) = self.frame_hit()
&& self.before_block(distance)
&& self.mob_hit().is_none_or(|(_, d)| d > distance)
&& let Some(name) = self.item_frames[i]
.item
.as_ref()
.and_then(|item| item.custom_name())
{
let frame = &self.item_frames[i];
let (lo, hi) = frame.bounds();
let center = glam::DVec3::from_array(std::array::from_fn(|a| (lo[a] + hi[a]) / 2.0));
if center.distance_squared(camera) < 64.0 * 64.0 {
// NAME_TAG is at the entity height (0.5), then the name
// submitter lifts it 0.5. ItemFrameRenderer offsets rendering
// 0.3 toward its facing and -0.25 vertically.
let step = frame.step();
tags.push(NameTag {
at: center
+ glam::DVec3::new(
f64::from(step[0] as f32 * 0.3),
0.75,
f64::from(step[2] as f32 * 0.3),
),
lines: vec![(Text::literal(name).resolve_legacy(), 0.0)],
light: self.world.light(
center.x.floor() as i32,
center.y.floor() as i32,
center.z.floor() as i32,
),
discrete: false,
});
}
}
tags
}
/// `SubmitNodeCollection.submitNameTag`: a discrete name is drawn once,
@@ -257,12 +293,16 @@ impl Game<'_> {
const FADED: u32 = 0x7F;
for tag in self.name_tags(camera, alpha) {
let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
// background, blends.
if tag.discrete {
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
continue;
}
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
self.mark_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
@@ -0,0 +1,110 @@
//! 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
//! pack that fails is refused with its problems in the chat while the last good one keeps
//! drawing.
use std::{
hash::{Hash, Hasher},
path::Path,
time::{Duration, Instant},
};
use horizon_client::text::Text;
use horizon_shader_api::{Pack, check};
use super::Game;
const LOOK_EVERY: Duration = Duration::from_millis(500);
const RED: u32 = 0xFF5555;
#[derive(Default)]
pub(super) struct NativeShaders {
/// The pack's files when it was last read (`fingerprint`).
stamp: Option<u64>,
looked: Option<Instant>,
}
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) {
let now = Instant::now();
let state = &mut self.native_shaders;
if state.looked.is_some_and(|t| now - t < LOOK_EVERY) {
return;
}
state.looked = Some(now);
let stamp = fingerprint(path);
if stamp == state.stamp {
return;
}
let reload = state.stamp.is_some();
state.stamp = stamp;
let Some(renderer) = &mut self.renderer else {
return;
};
let result = Pack::read(path).map_err(|e| vec![e]).and_then(|pack| {
let report = check(&pack)
.map_err(|p| p.iter().map(ToString::to_string).collect::<Vec<String>>())?;
let constants = pack
.manifest
.options
.iter()
.map(|o| (o.id.clone(), o.kind.default_constant()))
.collect();
let labels = renderer
.set_native_shader_pack(Some((report, constants)))
.map_err(|e| vec![e])?;
Ok((pack.manifest, labels))
});
let name = path.file_name().unwrap_or_default().to_string_lossy();
match result {
Ok((m, labels)) => {
log::info!(
"shader pack {} {}: {} in {} ms",
m.id,
m.version,
labels.join(", "),
now.elapsed().as_millis()
);
if reload {
self.system_message(Text::literal(format!("Shader pack {} reloaded", m.name)));
}
}
Err(problems) => {
log::warn!("shader pack {name} refused: {}", problems.join("; "));
self.system_message(Text::colored(format!("Shader pack {name} refused:"), RED));
for p in problems {
self.system_message(Text::colored(p, RED));
}
}
}
}
}
/// A hash of the pack's file names, sizes and modification times (`None` when unreadable).
fn fingerprint(path: &Path) -> Option<u64> {
fn walk(path: &Path, hasher: &mut std::hash::DefaultHasher) -> Option<()> {
let meta = std::fs::metadata(path).ok()?;
path.hash(hasher);
if meta.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(path)
.ok()?
.flatten()
.map(|e| e.path())
.collect();
entries.sort();
for entry in entries {
walk(&entry, hasher)?;
}
} else {
meta.len().hash(hasher);
meta.modified().ok()?.hash(hasher);
}
Some(())
}
let mut hasher = std::hash::DefaultHasher::new();
walk(path, &mut hasher)?;
Some(hasher.finish())
}
@@ -270,6 +270,18 @@ impl Game<'_> {
self.portals.player.set_inside(pos, PLAYER_DELAY);
self.portals.client_inside = true;
}
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
/// passenger), then only the client's `setAsInsidePortal`, which drives
/// the confusion effect (`client_portal_effect`).
pub(super) fn client_inside_nether_portal(&mut self) {
let alive = self
.player
.as_ref()
.is_some_and(|p| !p.health.dead_or_dying());
if alive && self.riding.is_none() {
self.portals.client_inside = true;
}
}
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
/// player then). Players wait `players_nether_portal_default_delay`
/// ticks, or the creative delay with invulnerable abilities.
+1 -8
View File
@@ -57,14 +57,7 @@ impl Game<'_> {
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or("options-set KEY VALUE: missing the value")?;
let mut file = self.settings.to_file();
file.set_text(key, value)?;
let mut settings = horizon_client::settings::Settings::from_file(file);
// Horizon's own files are not in options.txt.
std::mem::swap(&mut settings.horizon, &mut self.settings.horizon);
std::mem::swap(&mut settings.controller, &mut self.settings.controller);
std::mem::swap(&mut settings.extras, &mut self.settings.extras);
self.settings = settings;
self.settings = self.settings.with_option(key, value)?;
// As the options screen applies a change.
self.handle_option_events(vec![Event::Changed])?;
match *key {
+6
View File
@@ -142,6 +142,8 @@ impl Game<'_> {
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
let root =
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
self.reload_resources(&root)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
@@ -192,6 +194,10 @@ impl Game<'_> {
let context = pack.context.clone();
self.pack = pack;
self.gui = gui;
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
horizon_client::ui_theme::reset_atlas();
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
*self
@@ -412,7 +412,8 @@ impl Game<'_> {
}
/// Whether the player's own model shows in the world view.
fn player_model_shown(&self) -> bool {
!self.camera_type.first_person()
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
(!self.camera_type.first_person() || self.pack_shadow_casters.1)
&& self.sleeping.is_none()
&& !self.spectator()
&& self.potions.effects.get("minecraft:invisibility").is_none()
+255 -66
View File
@@ -58,6 +58,12 @@ fn remote_text(tag: &Tag) -> horizon_client::text::Text {
comp_text(&tag_to_json(tag))
}
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
/// component's styles, and the `§` codes a literal holds (`StringDecomposer`).
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>>);
@@ -123,6 +129,9 @@ pub(super) struct Remote {
loaded_sent: bool,
/// The view distance last sent in ClientInformation.
sent_view: i8,
/// The Chat option's visibility last sent in ClientInformation
/// (`Options.broadcastOptions` sends it again when it changes).
sent_chat_visibility: i32,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
@@ -130,8 +139,8 @@ pub(super) struct Remote {
predictions: HashMap<[i32; 3], (i32, i32)>,
/// Open container: (window id, menu name).
pub(super) menu: Option<(i32, String)>,
/// The open menu's title as the server named it.
pub(super) menu_title: Option<String>,
/// The open menu's title as the server named it, with its styles.
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
@@ -234,6 +243,22 @@ fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFo
}
impl Game<'_> {
/// A `Holder<SoundEvent>` off the wire: an inline event's location, or
/// the name of a registered one.
pub(super) fn remote_sound_name(
&self,
sound: &(i32, Option<(String, Option<f32>)>),
) -> Option<String> {
match &sound.1 {
Some((location, _)) => Some(location.clone()),
None => self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:sound_event")?
.name(sound.0)
.map(str::to_owned)
}),
}
}
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
self.connect_server_with_cookies(address, name, None)
@@ -406,6 +431,7 @@ impl Game<'_> {
abilities: None,
loaded_sent: false,
sent_view: view,
sent_chat_visibility: self.settings.chat_visibility as i32,
predictions: HashMap::new(),
menu: None,
menu_title: None,
@@ -473,6 +499,7 @@ impl Game<'_> {
r.session.disconnect();
}
self.server_packs_disconnect();
self.close_dialogs();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -614,14 +641,23 @@ impl Game<'_> {
)
}
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage).
pub(super) fn chat_line(&mut self, content: horizon_client::text::Text) {
/// events (ChatComponent.addMessage). The lines the Chat option hides
/// are dropped there (its `visibleMessageFilter`).
pub(super) fn chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) {
if !self.ui.chat.shows(source) {
return;
}
let text = content.plain();
log::info!("[chat] {text}");
self.ui.chat.add(
&self.gui.atlas,
content,
None,
source,
self.ticks,
self.screen == Screen::Chat,
);
@@ -684,6 +720,23 @@ impl Game<'_> {
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
self.remote.as_mut().unwrap().loaded_sent = true;
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
@@ -720,6 +773,8 @@ impl Game<'_> {
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
r.profile = uuid;
@@ -748,6 +803,8 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
@@ -1386,13 +1443,29 @@ impl Game<'_> {
m.on_ground = on_ground;
return;
}
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
p.id == id as u32
&& matches!(
p.kind,
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
)
}) {
if path.is_some() {
p.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
p.yaw = yaw;
p.pitch = pitch;
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
// InterpolationHandler: moveOrInterpolateTo puts them there at
// once, and their own tick moves them on between the server's
// updates (`tick_remote_entities`).
// ItemEntity, FallingBlockEntity and PrimedTnt keep Entity's
// NO_OP InterpolationHandler: moveOrInterpolateTo puts them there
// at once, and their own tick moves them on between the server's
// updates (`tick_remote_entities`, `tick_remote_projectiles`).
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.position = at;
@@ -1403,6 +1476,11 @@ impl Game<'_> {
f.position = at;
return;
}
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.position = at;
t.on_ground = on_ground;
return;
}
// AbstractInterpolationHandler.interpolateTo: without a rotation,
// the one it was heading for.
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
@@ -1596,9 +1674,10 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.sent_view = view;
}
self.send(S::ClientInformation(
horizon_protocol::packets::configuration::ClientInformation::new(view),
));
let mut info =
horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = self.settings.chat_visibility as i32;
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects: the server sends them again.
@@ -1710,20 +1789,14 @@ impl Game<'_> {
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
// level plays them (particles and sounds: a spawner's burst, bone
// meal, waxing, a block broken by someone else).
// ClientPacketListener.handleLevelEvent: a global one is heard
// from anywhere (ClientLevel.globalLevelEvent).
C::LevelEvent(p) if p.global => self.global_level_event_sound(p.kind, p.pos),
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
C::LevelParticles(p) => self.remote_level_particles(&p),
C::Explode(p) => self.remote_explode(&p),
C::Sound(p) => {
let name = match &p.sound.1 {
Some((location, _)) => Some(location.clone()),
None => self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:sound_event")?
.name(p.sound.0)
.map(str::to_owned)
}),
};
if let Some(name) = name {
if let Some(name) = self.remote_sound_name(&p.sound) {
let at = [
f64::from(p.x) / 8.0,
f64::from(p.y) / 8.0,
@@ -1906,10 +1979,14 @@ impl Game<'_> {
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let other = self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id);
let mannequin = self
.mobs
.iter()
.any(|m| m.id == p.id as u32 && &*m.kind.id == "minecraft:mannequin");
let other = mannequin
|| self.remote.as_ref().is_some_and(|r| {
r.level_players.contains_key(&p.id) && r.entity_id != p.id
});
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
// player lies down in their bed (drawn lying in it), or gets up.
if other {
@@ -1960,11 +2037,13 @@ impl Game<'_> {
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
// player's crouch (their model and Entity.isDiscrete's name tag).
if self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id))
// player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag).
if (mannequin
|| self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
for (index, _, value) in &p.values {
@@ -2367,6 +2446,16 @@ impl Game<'_> {
}
self.merge_remote_tree();
}
C::Cooldown(p) => {
// ClientPacketListener.handleItemCooldown: the server starts
// (or confirms) a group's cooldown; 0 ends it.
let group: Arc<str> = Arc::from(p.group.as_str());
if p.duration == 0 {
self.cooldowns.remove(&group);
} else {
self.cooldowns.add(group, p.duration);
}
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
@@ -2374,7 +2463,7 @@ impl Game<'_> {
if p.overlay {
self.titles.set_overlay(text, false);
} else {
self.chat_line(text);
self.chat_line(text, horizon_client::chat::ChatSource::Server);
}
}
C::PlayerChat(p) => {
@@ -2384,12 +2473,12 @@ impl Game<'_> {
.as_ref()
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let line = self.format_chat(&p.chat_type, content);
self.chat_line(line);
self.chat_line(line, horizon_client::chat::ChatSource::Player);
}
C::PlayerInfoUpdate(p) => {
if let Some(r) = self.remote.as_mut() {
@@ -2604,6 +2693,9 @@ impl Game<'_> {
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
C::ClearDialog(_) => self.clear_dialog(),
C::ClearTitles(p) => {
self.titles.clear();
if p.value {
@@ -2781,6 +2873,53 @@ impl Game<'_> {
{
mob.skin = Some(skin);
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
// player's, never required to be signed.
let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self
.mobs
.iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|m| {
let t = m
.profile
.as_ref()
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = t.map_or_else(Default::default, |t| {
let signature = if t.signature.is_some() {
horizon_client::skins::Signature::Signed
} else {
horizon_client::skins::Signature::Unsigned
};
horizon_client::skins::unpack(
&t.value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
});
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, false, |kind, url| {
downloads.state(kind, url)
})
};
resolved.push((id, skin));
}
for (id, skin) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.skin = skin;
}
}
}
/// Puts a downloaded skin in an atlas skin slot: a free one, else the
/// least recently shown.
@@ -2902,14 +3041,23 @@ impl Game<'_> {
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
/// entities whose own tick moves them on a client: dropped items and
/// falling blocks.
/// entities whose own tick moves them on a client: dropped items, falling
/// blocks and primed TNT.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3022,16 +3170,19 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Options.broadcastOptions: a changed render distance is sent again.
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
let visibility = self.settings.chat_visibility as i32;
if let Some(r) = self.remote.as_mut()
&& r.joined
&& r.sent_view != view
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
{
r.sent_view = view;
self.send(S::ClientInformation(
horizon_protocol::packets::configuration::ClientInformation::new(view),
));
r.sent_chat_visibility = visibility;
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
info.chat_visibility = visibility;
self.send(S::ClientInformation(info));
}
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
@@ -3192,6 +3343,12 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
_ => {}
}
}
@@ -3394,6 +3551,10 @@ impl Game<'_> {
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
*steps -= 1;
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
}
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
@@ -4113,8 +4274,12 @@ impl Game<'_> {
let Some(info) = self.res.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem runs the use here too: food starts
// being eaten (Consumable.startConsuming), a book and quill opens.
// MultiPlayerGameMode.useItem runs the use here too (inside
// startPrediction): the `use_cooldown` component starts its entry, and
// the server's ClientboundCooldownPacket confirms it.
self.apply_use_cooldown(&held);
// Food starts being eaten (Consumable.startConsuming), a book and
// quill opens.
self.with_hand(hand, |g| g.start_eating(&info));
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
@@ -4453,10 +4618,12 @@ impl Game<'_> {
_ => Ok(false),
}
}
/// ClientPacketListener.sendChat / sendCommand (unsigned).
/// ClientPacketListener.sendChat / sendCommand (unsigned). The Chat
/// option's Hidden and System levels keep chat out and Hidden keeps the
/// commands out (ChatAbilities).
pub(super) fn send_chat(&mut self, message: &str) {
let message = message.trim();
if message.is_empty() {
if message.is_empty() || !self.chat_abilities().allows(message) {
return;
}
if let Some(command) = message.strip_prefix('/') {
@@ -4667,6 +4834,34 @@ impl Game<'_> {
carried,
}));
}
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
/// -1, and the hand swings as `Player.drop` predicts.
pub(super) fn remote_creative_drop(&mut self, right: bool) {
let one = right && !self.creative_inventory_open();
let Some(carried) = self.carried.as_mut() else {
return;
};
let thrown = if one && carried.count > 1 {
carried.count -= 1;
ItemStack {
count: 1,
..carried.clone()
}
} else {
self.carried.take().expect("carried")
};
if !self.creative {
return;
}
self.swing_arm();
let item = self.to_network(&Some(thrown));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: -1,
item,
}));
}
pub(super) fn remote_close_container(&mut self) {
let id = self
.remote
@@ -4771,14 +4966,7 @@ impl Game<'_> {
return;
}
if let Some(kind) = Self::remote_station_kind(&menu) {
let title = plain(
&p.title,
&self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default(),
);
let title = menu_title(&p.title);
let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]);
self.remote_open_station(p.container, menu, kind, title, pos);
return;
@@ -4837,20 +5025,16 @@ impl Game<'_> {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
self.chat_line(horizon_client::text::Text::literal(format!(
"(Horizon cannot show {menu} screens yet)"
)));
self.chat_line(
horizon_client::text::Text::literal(format!(
"(Horizon cannot show {menu} screens yet)"
)),
horizon_client::chat::ChatSource::Client,
);
return;
}
};
let title = plain(
&p.title,
&self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default(),
);
let title = menu_title(&p.title);
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, menu.clone()));
r.menu_title = Some(title);
@@ -4894,7 +5078,7 @@ impl Game<'_> {
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
}
/// The title the server gave the menu it opened.
pub(super) fn remote_menu_title(&self) -> Option<String> {
pub(super) fn remote_menu_title(&self) -> Option<horizon_client::text::Text> {
self.remote.as_ref()?.menu_title.clone()
}
/// A language change reaches the server-sent components too: their titles
@@ -4907,7 +5091,7 @@ impl Game<'_> {
}
}
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
pub(super) fn remote_chest_menu(&self) -> Option<(usize, horizon_client::text::Text)> {
let r = self.remote.as_ref()?;
let rows = r
.menu
@@ -4916,7 +5100,12 @@ impl Game<'_> {
.strip_prefix("minecraft:generic_9x")?
.parse()
.ok()?;
Some((rows, r.menu_title.clone().unwrap_or_default()))
Some((
rows,
r.menu_title
.clone()
.unwrap_or_else(horizon_client::text::Text::empty),
))
}
/// ServerboundPlayerCommandPacket STOP_SLEEPING.
pub(super) fn remote_stop_sleeping(&mut self) {
@@ -75,6 +75,11 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
// Zombie's three): what VillagerProfessionLayer draws.
(Species::Villager, 19, Value::Ids(data))
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
// Mannequin.DATA_PROFILE (after Avatar's two): whose skin it
// wears; update_remote_skins resolves it like a tab player's.
(Species::Mannequin, 17, Value::Profile(profile)) => {
m.profile = Some(profile.clone());
}
_ => {}
}
}
@@ -295,4 +300,22 @@ mod tests {
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
assert_eq!(e.extra.variant2, 2);
}
#[test]
fn a_mannequin_exists_and_takes_its_profile() {
let Some(mut m) = mob("minecraft:mannequin") else {
return;
};
assert_eq!(&*m.kind.id, "minecraft:mannequin");
let profile = horizon_protocol::entity_data::Profile {
name: Some("Gommunity".into()),
uuid: Some(1),
..Default::default()
};
apply(
&mut m,
&[(17, s::RESOLVABLE_PROFILE, Value::Profile(profile.clone()))],
);
assert_eq!(m.profile, Some(profile));
}
}
@@ -220,10 +220,21 @@ impl Game<'_> {
/// One tick of interpolation, as `tick_remote_objects`.
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
for t in &mut self.tnts {
t.previous = t.position;
t.position = step(t.id, t.position);
}
// PrimedTnt.tick runs on a client too: it falls, bounces and burns its
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
// runs out; only the server explodes it.
let (world, collision, assets, facts) = (
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
);
self.tnts.retain_mut(|t| {
// A fluid lookup that fails leaves it where its move put it.
let _ = t.tick(world, collision, assets, facts);
t.fuse > 0
});
// FallingBlockEntity.tick runs on a client too: the block falls by
// itself between the server's updates (every 20 ticks), then waits
// where it lands for the server to place it and remove it.
@@ -238,7 +249,25 @@ impl Game<'_> {
for p in &mut self.creatures.projectiles {
p.previous = p.position;
p.age += 1;
p.position = step(p.id, p.position);
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
// AbstractHurtingProjectile.tick advances on the client between
// the tracker updates (ten ticks apart for a ghast's fireball).
let at = p.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
p.apply_hurting_inertia(water);
p.position = p.position.add_vector(p.velocity);
} else {
p.position = step(p.id, p.position);
}
}
for t in &mut self.tridents {
let id = t.id;
@@ -18,7 +18,7 @@ impl Game<'_> {
container: i32,
menu: String,
kind: Kind,
title: String,
title: horizon_client::text::Text,
pos: [i32; 3],
) {
if let Some(r) = self.remote.as_mut() {
+36 -6
View File
@@ -63,6 +63,12 @@ struct Steps {
on_ground: bool,
in_water: bool,
}
#[derive(Default)]
pub(super) struct PlayerEvents {
steps: Option<Steps>,
last_health: Option<f32>,
}
#[derive(Default)]
pub(super) struct SculkState {
/// The player's warning level toward a warden.
@@ -86,6 +92,15 @@ pub(super) struct SculkState {
}
impl SculkState {
pub(super) fn swap_player(&mut self, other: &mut PlayerEvents) {
let steps = self.steps.remove(&Who::Player);
if let Some(guest) = other.steps.take() {
self.steps.insert(Who::Player, guest);
}
other.steps = steps;
std::mem::swap(&mut self.last_health, &mut other.last_health);
}
/// Exchange the level-held parts (its sculk block entities and the
/// chunks scanned for them) with another level's (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
@@ -718,17 +733,24 @@ impl Game<'_> {
})
};
// The ticking ones in the index's order, with whether the chunks
// around them tick too.
// around them tick too. Most sensors and catalysts wait for a
// vibration or a death: their tick would change nothing, so they
// are passed over here (a vibration scheduled during these ticks is
// chosen on the next tick at the earliest, as in vanilla).
let entities = &self.world.block_entities;
let due: Vec<((i32, i32, i32), bool)> = self
.sculk
.index
.iter()
.filter_map(|&key| {
let (cx, cz) = (key.0 >> 4, key.2 >> 4);
ticks(cx, cz).then(|| {
let adjacent = (-1..=1).all(|dx| (-1..=1).all(|dz| ticks(cx + dx, cz + dz)));
(key, adjacent)
})
if !ticks(cx, cz)
|| matches!(entities.get(&key), Some(BlockEntity::Sculk(e)) if e.idle())
{
return None;
}
let adjacent = (-1..=1).all(|dx| (-1..=1).all(|dz| ticks(cx + dx, cz + dz)));
Some((key, adjacent))
})
.collect();
for (key, adjacent) in due {
@@ -1491,6 +1513,12 @@ impl Game<'_> {
/// Events from entities moving, getting hurt and dying, derived from what
/// changed this tick.
fn tick_entity_events(&mut self) {
self.tick_player_sculk_events();
self.tick_mob_sculk_events();
}
/// Entity.move emits on the authoritative server too, for each ServerPlayer.
pub(super) fn tick_player_sculk_events(&mut self) {
// The player.
let player = self.player.as_ref().map(|p| {
(
@@ -1518,7 +1546,9 @@ impl Game<'_> {
}
self.sculk.last_health = Some(health);
}
// Mobs.
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
.mobs
.iter()
@@ -156,6 +156,10 @@ const CLIENT_SOUNDS: &[&str] = &[
impl Game<'_> {
/// Run every scenario of `spec` ("SCENARIOS,OUTPUT") and write the results.
pub(super) fn sculk_fixture(&mut self, spec: &str) -> Result<()> {
assert!(
!self.options.execution_role.is_client(),
"fixture requires a server Game"
);
let (input, output) = spec
.split_once(',')
.ok_or("HORIZON_SCULK_FIXTURE=SCENARIOS,OUTPUT")?;
@@ -170,7 +170,7 @@ impl Game<'_> {
/// The player moves to `target`'s level (opened if nobody was there)
/// and is told it is there, at the approximate exit, while it loads. The
/// level whose tick is running is put back in place afterwards.
fn start_server_trip(
pub(super) fn start_server_trip(
&mut self,
host: &mut LanHost,
conn: u32,
+295 -10
View File
@@ -1,12 +1,34 @@
//! 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. The
//! 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;
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
use horizon_shaders::{BlockIds, Macros, Pack, Properties, dimension_folder, load_post};
use super::{Dim, Game};
use horizon_client::gfx::pack_draw::EntitySpan;
use std::cell::RefCell;
use std::collections::HashMap;
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
/// geometry.
pub(super) struct PackIds {
entities: HashMap<String, i32>,
blocks: BlockIds,
/// `blockEntityId` by block state, filled as states are drawn.
block_states: RefCell<HashMap<u32, i32>>,
/// The id of a name the file lacks: 65535, or 0 without the file.
missing_entity: i32,
missing_block: i32,
/// `item.properties` (4.1) and the id of a held item the file lacks, or of an empty hand:
/// −1, or 0 without the file (5.3).
items: HashMap<String, i32>,
missing_item: i32,
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
old_hand_light: bool,
}
impl Game<'_> {
/// Loads the pack for the current dimension when it changed, and returns this frame's
@@ -20,15 +42,18 @@ impl Game<'_> {
Dim::Nether => -1,
Dim::End => 1,
};
if let Some(renderer) = &mut self.renderer
let path = std::env::current_dir()
.unwrap_or_default()
.join("shaderpacks")
.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 started = std::time::Instant::now();
let path = std::env::current_dir()
.unwrap_or_default()
.join("shaderpacks")
.join(&*name);
match Pack::open(&path) {
Ok(pack) => {
let macros = Macros::standard();
@@ -42,8 +67,49 @@ impl Game<'_> {
);
}
let folder = dimension_folder(&pack, dimension);
let post = load_post(&pack, &folder, &properties, &macros, None);
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
let inputs = renderer.pack_inputs();
let mut post = load_post(&pack, &folder, &properties, &macros, &inputs);
let biomes = self.res.biomes();
let names: Vec<&str> = biomes.list.iter().map(|b| b.name.as_str()).collect();
post.custom.set_biomes(&names);
let text = pack.read_text("block.properties").unwrap_or_default();
let ids = BlockIds::parse(&Properties::parse(&text, &macros));
for note in &ids.notes {
log::warn!("shader pack {name}: block.properties: {note}");
}
let text = pack.read_text("entity.properties");
let entities = BlockIds::parse_kind(
&Properties::parse(text.as_deref().unwrap_or_default(), &macros),
"entity",
);
let text = pack.read_text("item.properties");
let items = BlockIds::parse_kind(
&Properties::parse(text.as_deref().unwrap_or_default(), &macros),
"item",
);
self.pack_ids = Some(PackIds {
items: items.plain(),
missing_item: if text.is_some() { -1 } else { 0 },
// OQ-21: on unless the pack turns it off.
old_hand_light: properties
.get("oldHandLight")
.is_none_or(|v| v.trim() != "false"),
entities: entities.plain(),
missing_entity: if text.is_some() { 65535 } else { 0 },
missing_block: if ids.is_empty() { 0 } else { 65535 },
blocks: ids.clone(),
block_states: RefCell::default(),
});
let started_ids = std::time::Instant::now();
let table = block_id_table(&self.pack, &ids);
log::info!(
"shader pack {name}: mc_Entity for {} of {} block sprites in {} ms",
table.chunks(2).filter(|p| p[0] != -1).count(),
table.len() / 2,
started_ids.elapsed().as_millis()
);
if let Some((programs, notes)) =
renderer.set_shader_pack(dimension, Some(post), &table)
{
for note in notes {
log::warn!("shader pack {name}: {note}");
@@ -57,14 +123,22 @@ impl Game<'_> {
}
}
Err(e) => {
self.pack_ids = None;
log::warn!("shader pack {name}: {e}");
renderer.set_shader_pack(dimension, None);
renderer.set_shader_pack(dimension, None, &[]);
}
}
}
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
let at = camera.floor().as_ivec3();
let light = self.world.light(at.x, at.y, at.z);
let biome = self
.world
.chunks
.get(&(at.x >> 4, at.z >> 4))
.map(|c| c.biome(at.x, at.y, at.z));
let (held_items, held_light) = self.held_for_pack();
let info = biome.map(|b| self.res.biomes().get(b));
PackInputs {
day_time: self.world.day_time,
rain: lerp(self.world.weather.rain_level),
@@ -79,6 +153,217 @@ impl Game<'_> {
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
hide_gui: self.ui.hide_gui,
brightness: self.settings.gamma,
biome: biome.map_or(-1, i32::from),
biome_category: info.map_or(0, |b| horizon_shaders::biome_category(&b.name)),
biome_precipitation: info.map_or(0, |b| {
horizon_shaders::biome_precipitation(b.precipitation, b.temperature)
}),
temperature: info.map_or(0.0, |b| b.temperature),
rainfall: info.map_or(0.0, |b| b.downfall),
held_items,
held_light,
}
}
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
if self.pack_ids.is_none() {
return ([0; 2], [0; 2]);
}
if self.luminance.is_none() {
self.luminance = Some(horizon_client::dynamic_light::Luminance::new(
self.res.facts(),
self.pack.assets.registry,
));
}
let (Some(ids), Some(lum)) = (&self.pack_ids, &self.luminance) else {
return ([0; 2], [0; 2]);
};
let hands = [self.selected, super::shield::OFFHAND_SLOT].map(|slot| {
let item = self.stack_in(slot).map(|s| &*s.item);
let id = item.and_then(|i| ids.items.get(i).copied());
(
id.unwrap_or(ids.missing_item),
item.map_or(0, |i| i32::from(lum.of(i))),
)
});
let first = if ids.old_hand_light && hands[1].1 > hands[0].1 {
hands[1]
} else {
hands[0]
};
([first.0, hands[1].0], [first.1, hands[1].1])
}
}
impl Game<'_> {
/// Starts a span of the entity geometry at vertex `start` for entity type `name`
/// (`minecraft:zombie`; "" for one the pack cannot name). Nothing without a pack.
pub(super) fn mark_entity(&self, start: usize, name: &str) {
self.mark_entity_part(start, name, false);
}
/// Starts a span for entity geometry that blends (name tags, shadows).
pub(super) fn mark_translucent_entity(&self, start: usize, name: &str) {
self.mark_entity_part(start, name, true);
}
fn mark_entity_part(&self, start: usize, name: &str, translucent: bool) {
if let Some(ids) = &self.pack_ids {
let id = ids.entities.get(name).copied();
self.push_span(start, "ENTITIES", id.unwrap_or(ids.missing_entity));
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
last.translucent = translucent;
}
}
}
/// Starts a span for the block entity of block state `state`.
pub(super) fn mark_block_entity(&self, start: usize, state: u32) {
let Some(ids) = &self.pack_ids else { return };
let id = *ids
.block_states
.borrow_mut()
.entry(state)
.or_insert_with(|| {
let registry = self.pack.assets.registry;
registry
.state(horizon_world::registry::BlockStateId(state))
.and_then(|s| {
ids.blocks
.id(&s.name, |k| s.properties.get(k).map(String::as_str))
})
.unwrap_or(ids.missing_block)
});
self.push_span(start, "BLOCK_ENTITIES", id);
let emission = self
.res
.facts()
.get(horizon_world::registry::BlockStateId(state))
.emission;
if let Some(last) = self.entity_spans.borrow_mut().last_mut() {
last.emissive = emission > 0;
}
}
/// Starts the span of the local player's model; `hidden` in first person, where only a
/// pack's shadow pass draws it.
pub(super) fn mark_player(&self, start: usize, hidden: bool) {
self.mark_entity(start, "minecraft:player");
if self.pack_ids.is_some()
&& let Some(last) = self.entity_spans.borrow_mut().last_mut()
{
last.player = true;
last.hidden = hidden;
}
}
/// Marks vertices `start..end` (one entity) as hidden from the camera, for an entity
/// Horizon's culling would drop but a pack's shadow pass still draws (`EntitySpan::hide`).
pub(super) fn hide_spans_from(&self, start: usize, end: usize) -> bool {
EntitySpan::hide(&mut self.entity_spans.borrow_mut(), start, end)
}
/// Starts a span of geometry that is not an entity (`PARTICLES`, `DESTROY`...).
pub(super) fn mark_stage(&self, start: usize, stage: &str) {
if self.pack_ids.is_some() {
self.push_span(start, stage, 0);
}
}
fn push_span(&self, start: usize, stage: &str, id: i32) {
let mut spans = self.entity_spans.borrow_mut();
let span = EntitySpan {
start: start as u32,
stage: horizon_shaders::render_stage(stage),
id,
..Default::default()
};
// A span that drew nothing gives way to the next.
match spans.last_mut() {
Some(last) if last.start == span.start => *last = span,
_ => spans.push(span),
}
}
}
/// `mc_Entity` (x, y) of each block sprite layer, as (id, fluid) pairs: terrain vertices carry
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
/// (`block/oak_leaves` for `oak_leaves`), else the id most blocks drawing it share (ties: the
/// lowest); -1 when no block drawing it has one. y is 1 for the fluid sprites, -1 otherwise.
fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
use std::collections::{BTreeMap, BTreeSet};
let layers = pack
.render
.sprite_layers
.values()
.max()
.map_or(0, |l| l + 1) as usize;
let mut table = vec![-1; layers * 2];
if ids.is_empty() {
return table;
}
let registry = pack.assets.registry;
let catalog = &pack.render.model_catalog;
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
// (layer, id) -> (a block named like the sprite, the blocks).
let mut votes: BTreeMap<(u32, i32), (bool, BTreeSet<&str>)> = BTreeMap::new();
for i in 0..registry.len() {
let state_id = horizon_world::registry::BlockStateId(i as u32);
let Some(state) = registry.state(state_id) else {
continue;
};
let Some(id) = ids.id(&state.name, |k| state.properties.get(k).map(String::as_str)) else {
continue;
};
let block = state.name.strip_prefix("minecraft:").unwrap_or(&state.name);
let mut vote = |layer: u32| {
let named = pack
.layer_names
.get(&layer)
.and_then(|n| n.strip_prefix("block/"))
== Some(block);
let v = votes.entry((layer, id)).or_default();
v.0 |= named;
v.1.insert(&state.name);
};
// Fluids draw from their own sprites.
let fluid = match block {
"water" => ["water_still", "water_flow"],
"lava" => ["lava_still", "lava_flow"],
_ => ["", ""],
};
for f in fluid.iter().filter(|f| !f.is_empty()) {
if let Some(l) = sprite(&format!("minecraft:block/{f}")) {
vote(l);
}
}
let origin = horizon_core::position::BlockPos::new(0, 0, 0);
for (_, model) in catalog.collect(state_id, origin).unwrap_or_default() {
for face in &model.faces {
vote(face.layer);
}
}
}
let mut best: BTreeMap<u32, (bool, usize, i32)> = BTreeMap::new();
for ((layer, id), (named, blocks)) in votes {
let candidate = (named, blocks.len(), -id);
let b = best.entry(layer).or_insert(candidate);
if candidate > *b {
*b = candidate;
}
}
for (layer, (_, _, id)) in best {
if let Some(slot) = table.get_mut(layer as usize * 2) {
*slot = -id;
}
}
for f in ["water_still", "water_flow", "lava_still", "lava_flow"] {
if let Some(l) = sprite(&format!("minecraft:block/{f}"))
&& let Some(slot) = table.get_mut(l as usize * 2 + 1)
{
*slot = 1;
}
}
table
}
@@ -272,8 +272,6 @@ impl Loading {
}
pub(super) fn apply_menu_settings(&mut self) {
if let Some(renderer) = &mut self.renderer {
self.settings
.clamp_gui_scale([renderer.config.width as i32, renderer.config.height as i32]);
renderer.gamma = self.settings.gamma;
renderer.set_present_mode(self.settings.vsync, self.settings.horizon.present_mode);
}
+15 -11
View File
@@ -11,6 +11,7 @@ use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
stations::{self, AnvilResult, EnchantOffers},
text::Text,
};
use horizon_core::random::{LegacyRandom, RandomSource};
@@ -1160,15 +1161,15 @@ impl Game<'_> {
}
}
let default = self.translate("container.brewing", "Brewing Stand");
let title = self
.station
.as_ref()
.and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2])))
.cloned()
.or_else(|| self.remote_menu_title())
.unwrap_or(default);
let tw = HudBatch::text_width(atlas, &title);
batch.text(
let title = self.remote_menu_title().unwrap_or_else(|| {
let named = self
.station
.as_ref()
.and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2])));
Text::literal(named.cloned().unwrap_or(default)).resolve_legacy()
});
let tw = atlas.font.width_text(&title);
batch.text_styled(
atlas,
&title,
ox + (176.0 - tw) / 2.0,
@@ -1195,7 +1196,10 @@ impl Game<'_> {
}
}
} else if let Some(kind) = self.station.as_ref().map(|s| s.kind) {
let title = self.translate(kind.title_key(), "");
// A server's menu has the title it was given.
let title = self
.remote_menu_title()
.unwrap_or_else(|| Text::literal(self.translate(kind.title_key(), "")));
{
let atlas = &self.gui.atlas;
Self::part(
@@ -1207,7 +1211,7 @@ impl Game<'_> {
(ox, oy),
);
let (tx, ty) = kind.title_pos();
batch.text(atlas, &title, ox + tx, oy + ty, GREY, false);
batch.text_styled(atlas, &title, ox + tx, oy + ty, GREY, false);
batch.text(atlas, &inventory_label, ox + 8.0, oy + 72.0, GREY, false);
}
if kind == Kind::Cartography {
+51 -9
View File
@@ -158,7 +158,8 @@ const COLUMNS: [(&str, &str); 6] = [
#[derive(Default)]
pub(super) struct StatsUi {
tab: usize,
scroll: f32,
/// The list's scrolling: the wheel and the held scrollbar.
scroll: horizon_client::ui::Scroll,
sort: Option<(usize, i32)>,
}
/// `MenuTabBar` geometry: three tabs over at most 400 pixels.
@@ -182,6 +183,15 @@ impl Game<'_> {
self.stats_ui = StatsUi::default();
self.screen = Screen::Stats;
}
/// The list's scrollbar column (right of the 280 pixel list) and the
/// pixels it scrolls.
fn stats_bar(w: f32) -> f32 {
((w - 280.0) / 2.0).floor() + 280.0 + 6.0
}
fn stats_content(&self) -> f32 {
let (row_h, rows) = self.stats_rows();
row_h * rows as f32 + 4.0
}
fn stats_rows(&self) -> (f32, usize) {
match self.stats_ui.tab {
0 => (14.0, CUSTOM.len()),
@@ -260,10 +270,9 @@ impl Game<'_> {
// The list.
let (row_h, rows) = self.stats_rows();
let content = row_h * rows as f32 + 4.0;
let max_scroll = (content - (bottom - top)).max(0.0);
self.stats_ui.scroll = self.stats_ui.scroll.clamp(0.0, max_scroll);
self.stats_ui.scroll.clamp(top, bottom, content);
let left = ((w - 280.0) / 2.0).floor();
let scroll = self.stats_ui.scroll;
let scroll = self.stats_ui.scroll.amount;
let row_y = |i: usize| top + 4.0 + i as f32 * row_h - scroll;
let visible = |y: f32| y >= top && y + row_h <= bottom;
match self.stats_ui.tab {
@@ -408,11 +417,11 @@ impl Game<'_> {
ui::scrollbar(
batch,
atlas,
left + 280.0 + 6.0,
Self::stats_bar(w),
top,
bottom,
content,
self.stats_ui.scroll,
self.stats_ui.scroll.amount,
);
self.draw_stats_footer(batch, w, h);
}
@@ -433,6 +442,10 @@ impl Game<'_> {
);
}
pub(super) fn stats_click(&mut self, size: [u32; 2]) {
// A press on the scrollbar holds it and clicks nothing else.
if self.stats_press_scrollbar(size) {
return;
}
let b = HudBatch::new(size);
let (w, h) = (b.width, b.height);
let (x0, tw) = tabs(w);
@@ -441,7 +454,7 @@ impl Game<'_> {
&& self.stats_ui.tab != i
{
self.stats_ui.tab = i;
self.stats_ui.scroll = 0.0;
self.stats_ui.scroll.amount = 0.0;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return;
@@ -461,7 +474,7 @@ impl Game<'_> {
}
if self.stats_ui.tab == 1 {
let left = ((w - 280.0) / 2.0).floor();
let y = TAB_H + 4.0 - self.stats_ui.scroll;
let y = TAB_H + 4.0 - self.stats_ui.scroll.amount;
for c in 0..COLUMNS.len() {
let x = left + 75.0 + 40.0 * c as f32 - 18.0;
if ui::hovered(self.cursor, x, y + 1.0, 18.0, 18.0) {
@@ -479,6 +492,35 @@ impl Game<'_> {
}
pub(super) fn stats_scroll(&mut self, amount: f32) {
let (row_h, _) = self.stats_rows();
self.stats_ui.scroll -= amount * row_h / 2.0;
let h = self.gui_size().1;
let content = self.stats_content();
self.stats_ui
.scroll
.wheel(amount, row_h / 2.0, TAB_H, h - FOOTER, content);
}
/// A press on the statistics list's scrollbar holds it
/// (`AbstractSelectionList.updateScrolling`).
pub(super) fn stats_press_scrollbar(&mut self, size: [u32; 2]) -> bool {
let b = HudBatch::new(size);
let (w, h) = (b.width, b.height);
let content = self.stats_content();
self.stats_ui
.scroll
.press(self.cursor, Self::stats_bar(w), TAB_H, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub(super) fn stats_drag(&mut self) {
if !self.stats_ui.scroll.held() {
return;
}
let h = self.gui_size().1;
let content = self.stats_content();
self.stats_ui
.scroll
.drag(self.cursor.1, TAB_H, h - FOOTER, content);
}
/// The button came up: the scrollbar lets go.
pub(super) fn stats_release(&mut self) {
self.stats_ui.scroll.release();
}
}
+4 -4
View File
@@ -2,7 +2,7 @@
//! kept in the dropped item's `container` component) and ender chests (the
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack};
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
pub(super) fn is_shulker_box(name: &str) -> bool {
name.ends_with("shulker_box")
@@ -233,7 +233,7 @@ impl Game<'_> {
generic.then(|| self.container_size() / 9)
}
/// The title of the open chest-like menu.
fn chest_title(&self, rows: usize) -> String {
fn chest_title(&self, rows: usize) -> Text {
if let Some((_, title)) = self.remote_chest_menu() {
return title;
}
@@ -246,7 +246,7 @@ impl Game<'_> {
_ if rows > 3 => ("container.chestDouble", "Large Chest"),
_ => ("container.chest", "Chest"),
};
self.container_title(self.translate(key, fallback))
Text::literal(self.container_title(self.translate(key, fallback))).resolve_legacy()
}
/// `ContainerScreen` / `ShulkerBoxScreen`: the background with its
/// container rows, the title and the inventory label.
@@ -294,7 +294,7 @@ impl Game<'_> {
r.u1 = r.u0 + 176.0 * du;
batch.rect_uv(ox, oy + top, 176.0, 96.0, r, u32::MAX);
}
batch.text(
batch.text_styled(
atlas,
&self.chest_title(rows),
ox + 8.0,
+1 -1
View File
@@ -33,7 +33,7 @@ impl PrimedTnt {
),
)
}
fn tick(
pub(super) fn tick(
&mut self,
world: &horizon_client::world::World,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
-4
View File
@@ -52,10 +52,6 @@ fn triggers(name: &str) -> bool {
impl Game<'_> {
/// `WindChargeItem.use`.
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
if self.creatures.wind_cooldown > 0 {
return Ok(true);
}
self.creatures.wind_cooldown = 10;
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
return Ok(false);
};
@@ -246,6 +246,9 @@ pub(super) fn make_streaming(
let inner = Arc::new(VanillaTerrain::for_world(&options.resources.join("worldgen"), pack.assets.registry, res.biomes(), seed, dim.id(), structures).map_err(|e| {
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
})?);
if dim == Dim::Overworld && structures {
inner.search_rings_behind();
}
locator = Some(inner.clone());
Streaming::new(
Arc::new(Persistent {
+527 -46
View File
@@ -67,10 +67,18 @@ mod end_sky;
mod flat;
pub mod lightning;
mod lod;
mod native_post;
mod occlusion;
mod pack_atlas;
pub mod pack_draw;
mod pack_entities;
mod pack_hand;
mod pack_post;
mod pack_shadow;
mod pack_sky;
mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
mod page_runs;
pub mod panorama;
mod resident;
@@ -86,6 +94,19 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
/// The world pass again after occlusion culling's test: it loads both
/// targets (phase 2 draws over phase 1, reading the test's results).
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
fn sky_open(frame: &Frame) -> bool {
match frame.fog {
Some(f) => f.sky_end > 2.0,
None => !frame.underwater,
}
}
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
fn world_pass_after_culling<'e>(
encoder: &'e mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
@@ -232,6 +253,8 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -449,6 +472,8 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
shadow_visible: Vec<resident::Visible>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -463,6 +488,8 @@ pub struct Renderer {
/// Each section's solid draws, to group them by arena page.
solid_runs: page_runs::PageRuns,
phase2_runs: page_runs::PageRuns,
/// The shadow camera's draws by page, in bands of distance from the light.
shadow_runs: page_runs::PageRuns,
/// Block-entity blocks per meshed section (world position, state id).
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
/// Changes whenever `block_entities` does (unique across renderers).
@@ -473,8 +500,20 @@ pub struct Renderer {
world_post: world_post::Post,
/// 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>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
pack_entity_rest: Vec<Vertex>,
/// Set 1 of the cloud pipeline, for a shader pack's `gbuffers_clouds`.
cloud_bindings: wgpu::BindGroupLayout,
/// Rain and snow handed to a shader pack's `gbuffers_weather`, once a pack draws them.
pack_weather: Option<pack_weather::PackWeather>,
/// The block sprites as one atlas, while a shader pack is loaded.
pack_atlas: Option<pack_atlas::PackAtlas>,
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -842,12 +881,17 @@ async fn open_device(
continue;
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory.
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS));
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
@@ -1484,7 +1528,10 @@ impl Renderer {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
// COPY_SRC: a shader pack's weather copies the rain and snow sprites out.
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
@@ -2149,13 +2196,16 @@ impl Renderer {
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
// Readable as storage where flat draws run: a shader pack's terrain program reads them.
let arena = arena::Arena::new(
&device,
std::mem::size_of::<TerrainVertex>() as u64,
1 << 20,
flat_wanted,
);
// Sections as one index list per frame where multi-draw is emulated.
let flat = flat::wanted(&adapter, &device).then(|| {
let flat = flat_wanted.then(|| {
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
@@ -2349,6 +2399,7 @@ impl Renderer {
multi_draw,
visible: Vec::new(),
visible_lods: Vec::new(),
shadow_visible: Vec::new(),
translucent_draws: Vec::new(),
origins: Vec::new(),
draws: Vec::new(),
@@ -2357,12 +2408,19 @@ impl Renderer {
phase2_pages: Vec::new(),
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
block_entities: HashMap::new(),
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
pack_post: None,
native_post: None,
pass_timing: pack_timing::PassTiming::logging(),
pack_entity_rest: Vec::new(),
cloud_bindings,
pack_weather: None,
pack_atlas: None,
pack_terrain_flat: false,
hud,
menu_blur: None,
info,
@@ -2548,6 +2606,18 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
/// entities other than the player, so those Horizon's culling hides from the camera are
/// kept for it; the local player, so its model is built in first person too). (false,
/// false) without a pack or its shadow pass.
pub fn pack_shadow_casters(&self) -> (bool, bool) {
let Some((_, Some(post))) = &self.pack_post else {
return (false, false);
};
let c = post.shadow_casters();
((c.entities && !c.only_player) || c.blocks, c.entities)
}
pub fn has_section(&self, key: SectionKey) -> bool {
self.sections.contains(key)
}
@@ -2793,24 +2863,114 @@ impl Renderer {
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias;
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`.
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
/// Returns the programs that run and the notes of loading them.
pub fn set_shader_pack(
&mut self,
dimension: i32,
pack: Option<horizon_shaders::Post>,
block_ids: &[i32],
) -> Option<(Vec<String>, Vec<String>)> {
if pack.is_none() {
self.pack_atlas = None;
}
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let vertex = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
};
let buffers = [Some(vertex), Some(instances)];
let flat = self.pack_terrain_flat;
let post = pack.map(|post| {
let world = pack_post::World {
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
lightmap: self.lightmap_view.clone(),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
depth_write: true,
atlas: None,
ids: None,
cull: true,
}),
block_ids,
celestial: self
.hud
.atlas_texture
.as_ref()
.map(|t| t.create_view(&Default::default())),
clouds: Some(&self.cloud_bindings),
weather: true,
hand: true,
};
pack_post::PackPost::new(
&self.device,
&self.queue,
post,
self.config.format.remove_srgb_suffix(),
world,
)
});
if post.is_none() {
self.pack_atlas = None;
} else if self.pack_atlas.is_none() {
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
});
self.pack_post = Some((dimension, post));
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
self.native_post = 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.
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;
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);
Ok(labels)
}
/// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and
/// terrain's in the pack's block atlas (made here when missing; none when it does not fit).
pub fn pack_inputs(&mut self) -> horizon_shaders::GameInputs {
horizon_shaders::GameInputs {
terrain: self.pack_terrain_inputs(),
sky: Some(pack_sky::inputs()),
celestial: Some(pack_sky::celestial_inputs()),
entities: Some(pack_entities::inputs()),
clouds: Some(pack_entities::cloud_inputs()),
weather: Some(pack_weather::inputs()),
hand: Some(pack_hand::inputs()),
}
}
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
if self.pack_atlas.is_none() {
let mut encoder = self.device.create_command_encoder(&Default::default());
let layers = self
.sprite_textures
@@ -2827,12 +2987,41 @@ impl Renderer {
);
self.queue.submit([encoder.finish()]);
}
let report = post.as_ref().map(|p| {
let programs = p.programs().into_iter().map(str::to_owned).collect();
(programs, p.notes.clone())
});
self.pack_post = Some((dimension, post));
report
// Flat draws whenever the device has them (`render` keeps them on with a pack).
self.pack_terrain_flat = self.flat.is_some() && self.flat_on;
Some(
self.pack_atlas
.as_ref()?
.terrain_inputs(self.pack_terrain_flat),
)
}
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
/// with `HORIZON_SHADER_PACK_TIMING`.
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
native: &'a mut Option<native_post::NativePost>,
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
match (pack, native) {
(Some((_, Some(post))), _) => Some(&mut post.timing),
(_, Some(post)) => Some(&mut post.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) {
(Some((_, Some(post))), _) => post.timing.as_ref(),
(_, Some(post)) => post.timing.as_ref(),
_ => None,
};
timer
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The dimension the shader pack was last set for.
pub fn shader_pack_dimension(&self) -> Option<i32> {
@@ -3208,8 +3397,43 @@ impl Renderer {
);
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
}
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul.
let shadow_view = self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.filter(|_| !frame.title)
.and_then(|p| p.shadow_view(frame));
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
shadow_visible.clear();
if let Some(v) = &shadow_view {
let radius = (frame.detail_distance / 16.0) as i32;
let radius = v
.distance
.map_or(radius, |d| radius.min((d / 16.0).ceil() as i32));
let light = Frustum {
planes: frustum(v.cull),
};
crate::spans::time("shadow visibility", || {
self.sections.visible(
camera,
radius,
&light,
|_| true,
|_, _| false,
&mut shadow_visible,
)
});
// Nearest the light first, so the depth test rejects what lies under it.
let light = v.light;
shadow_visible.sort_unstable_by(|a, b| {
let height = |s: &resident::Visible| glam::Vec3::from(s.origin).dot(light);
height(b).total_cmp(&height(a))
});
}
self.drawn_sections = visible.len() + visible_lods.len();
let instances = self.drawn_sections;
let instances = self.drawn_sections + shadow_visible.len();
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -3226,7 +3450,8 @@ impl Renderer {
visible
.iter()
.map(|v| section_instance(v.origin))
.chain(visible_lods.iter().map(|v| lod::instance(v.origin))),
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
);
if !self.origins.is_empty() {
self.uploads.buffer(
@@ -3243,11 +3468,23 @@ impl Renderer {
// Occlusion culling leaves the sections its last result did not see
// to phase 2, after the others, with instance counts of 0. Whether
// the frame has draws enough goes by the last one's.
// A shader pack drawing terrain draws all of it: occlusion culling reads the depth of
// Horizon's own terrain draw.
let pack_terrain = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
let mut cull = self
.cull
.as_mut()
.filter(|c| {
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
self.cull_on
&& !frame.title
&& !pack_terrain
&& self.multi_draw
&& visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
self.phase2_draws.clear();
@@ -3418,9 +3655,87 @@ impl Renderer {
self.translucency_cursor += budget;
let translucent_draws = self.draws.len() as u32 - solid_draws;
self.section_draws = self.draws.len();
// The shadow camera's sections after the camera's: quads that can face the light, then
// translucent ones (in their last sorted order; depth decides there).
let shadow_first = self.draws.len() as u32;
let mut shadow_translucent = Vec::new();
self.shadow_runs.clear();
if let Some(view) = &shadow_view {
let base = self.drawn_sections;
let toward = view.light * 1.0e5;
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
// takes distances): each band's draws go by page, a call per page and band.
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
let top = shadow_visible.first().map_or(0.0, height);
for (k, v) in shadow_visible.iter().enumerate() {
let s = self.sections.get_mut(v.slot);
let instance = (base + k) as u32;
let depth = f64::from(top - height(v)).max(0.0);
let distance = (depth * depth).to_bits();
// A section the camera never saw has no sorted indices yet: sorted once now.
if let Some((index_page, index_start)) = s.sorted_indices
&& !s.sort.is_sorted()
{
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
v.key.2 * 16,
];
if let Some(indices) =
s.sort
.update(camera.to_array(), origin, &mut self.translucent_sorter)
{
self.uploads.buffer(
&mut encoder,
self.sorted_indices.buffer(index_page),
index_start * 4,
bytemuck::cast_slice(indices),
);
}
}
let before = self.draws.len();
facing_runs(&s.sides, Some(toward), v.origin, |first, quads| {
let start = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, start, instance]);
self.draw_pages.push((s.page, None));
});
let len = self.draws.len() - before;
self.shadow_runs.push(distance, s.page, before, len, [0; 3]);
if let Some((index_page, index_start)) = s.sorted_indices {
let start = s.start + u64::from(s.solid) * 4;
shadow_translucent.push((
distance,
[
s.translucent * 6,
1,
index_start as u32,
start as u32,
instance,
],
(s.page, Some(index_page)),
));
}
}
}
let shadow_solid = self.draws.len() as u32 - shadow_first;
self.shadow_runs
.regroup(&mut self.draws, &mut self.draw_pages, shadow_first as usize);
self.shadow_runs.clear();
for (distance, draw, pages) in shadow_translucent {
self.shadow_runs
.push(distance, pages.0, self.draws.len(), 1, [0; 3]);
self.draws.push(draw);
self.draw_pages.push(pages);
}
self.shadow_runs.regroup(
&mut self.draws,
&mut self.draw_pages,
(shadow_first + shadow_solid) as usize,
);
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
// Metal replays a multi-draw as one draw call per entry.
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
(0..self.draws.len())
(0..self.section_draws)
.filter(|&i| {
i == 0
|| i == phase1_draws as usize
@@ -3429,7 +3744,7 @@ impl Renderer {
})
.count()
} else {
self.draws.len()
self.section_draws
};
let debug_depth = frame.debug_lines.len() as u32;
let debug_top = frame.debug_lines_on_top.len() as u32;
@@ -3472,6 +3787,22 @@ impl Renderer {
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
// While a shader pack draws entities, the runs of `entity_spans` its programs draw are
// left out of Horizon's own entity draw.
let mut entities = frame.entities;
if !frame.title
&& let Some((_, Some(post))) = &mut self.pack_post
&& post.has_entities()
{
post.prepare_entities(
&self.device,
&self.queue,
frame.entities,
frame.entity_spans,
&mut self.pack_entity_rest,
);
entities = &self.pack_entity_rest;
}
let [
entity_quads,
crumbling_quads,
@@ -3479,12 +3810,9 @@ impl Renderer {
water_mask_quads,
shadow_quads,
translucent_particle_quads,
] = self.entity_batch.update(
frame.entities,
frame.additive,
frame.hand,
frame.see_through,
);
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ crumbling_quads
@@ -3653,7 +3981,9 @@ impl Renderer {
Some(flat)
if self.flat_on
&& !self.draws.is_empty()
&& self.drawn_sections >= flat.min_sections =>
&& (self.drawn_sections >= flat.min_sections
|| pack_terrain
|| shadow_view.is_some()) =>
{
let sections = &self.sections;
let lods = &self.lods;
@@ -3668,9 +3998,11 @@ impl Renderer {
&self.draw_pages,
phase1_draws as usize,
solid_draws as usize,
[shadow_first, shadow_first + shadow_solid].map(|d| d as usize),
phase2,
visible
.iter()
.chain(&shadow_visible)
.filter_map(|v| {
let id = sections.get(v.slot).flat_id?;
Some((id, section_instance(v.origin)))
@@ -3686,7 +4018,7 @@ impl Renderer {
_ => false,
};
if flat_frame {
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.calls.len());
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
} else if self.multi_draw && !self.draws.is_empty() {
if self.draws.len() as u64 > self.indirect_capacity {
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
@@ -3713,10 +4045,61 @@ 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)
&& timer.is_some() != self.pass_timing
{
*timer = self
.pass_timing
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
let pack_reads_world = pack_post || native_post;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
let pack_draws_shadow = shadow_view.is_some() && flat_frame == self.pack_terrain_flat;
let pack_draws_sky = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_sky());
let pack_draws_celestial = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_celestial());
let pack_draws_water = pack_draws_terrain
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_water());
let pack_draws_clouds = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_clouds());
let pack_draws_hand = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_hand());
let pack_draws_weather = pack_post
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_weather());
let scaled = self.world_post.prepare(
&self.device,
[self.config.width, self.config.height],
pack_post,
pack_reads_world,
);
let (world_view, world_depth) = match &scaled {
Some((color, depth)) => (color, depth),
@@ -3755,7 +4138,7 @@ impl Renderer {
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
store: if cull.is_some() || pack_post {
store: if cull.is_some() || pack_reads_world {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
@@ -3766,13 +4149,9 @@ impl Renderer {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
let sky_visible = match frame.fog {
Some(f) => f.sky_end > 2.0,
None => !frame.underwater,
};
let sky_visible = sky_open(frame);
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
if !frame.title && frame.skybox {
if !frame.title && frame.skybox && !pack_draws_sky {
pass.set_pipeline(&self.sky);
pass.draw(0..3, 0..1);
}
@@ -3786,22 +4165,29 @@ impl Renderer {
if frame.end_sky {
self.end_sky.draw(&mut pass);
}
if celestial_vertices > 0 {
if celestial_vertices > 0 && !pack_draws_celestial {
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.stars.is_some() {
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
self.stars.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.dark_disc {
if !frame.title && frame.dark_disc && !pack_draws_sky {
pass.set_pipeline(&self.dark_disc);
pass.draw(0..3, 0..1);
}
}
match self.flat.as_ref().filter(|_| flat_frame) {
match self
.flat
.as_ref()
.filter(|_| flat_frame && !pack_draws_terrain)
{
_ if pack_draws_terrain => {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
Some(flat) => {
pass.set_pipeline(&flat.solid);
let vertices = |p| self.arena.buffer(p);
@@ -3818,7 +4204,7 @@ impl Renderer {
&mut encoder,
world_view,
world_depth,
pack_post,
pack_reads_world,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&flat.solid);
@@ -3847,7 +4233,7 @@ impl Renderer {
&mut encoder,
world_view,
world_depth,
pack_post,
pack_reads_world,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&self.solid);
@@ -3875,7 +4261,9 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
self.weather.draw(&mut pass, &self.entity_group);
if !pack_draws_weather {
self.weather.draw(&mut pass, &self.entity_group);
}
pass.set_bind_group(0, &self.group, &[]);
if crumbling_quads > 0 {
pass.set_pipeline(&self.crumbling_pipeline);
@@ -3937,9 +4325,11 @@ impl Renderer {
pass.draw(0..4, debug_depth..debug_depth + debug_top);
}
match self.flat.as_ref().filter(|_| flat_frame) {
// The pack draws translucent terrain after its deferred programs.
_ if pack_draws_water => {}
Some(flat) => {
pass.set_pipeline(&flat.translucent);
flat.draw(&mut pass, &flat.calls[flat.solid_calls..], |p| {
flat.draw(&mut pass, flat.translucent_calls(), |p| {
self.arena.buffer(p)
});
pass.set_bind_group(0, &self.group, &[]);
@@ -4004,7 +4394,7 @@ impl Renderer {
0..1,
);
}
if cloud_quads > 0 {
if cloud_quads > 0 && !pack_draws_clouds {
pass.set_pipeline(&self.cloud_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
@@ -4025,9 +4415,79 @@ impl Renderer {
}
if let Some((color, depth)) = &scaled
&& pack_post
&& let Some((_, Some(post))) = &mut self.pack_post
&& let Some((dimension, Some(mut post))) = self.pack_post.take()
{
let size = self.world_post.size().unwrap_or([1, 1]);
let weather = match &self.hud.atlas_texture {
Some(atlas) if pack_draws_weather => self
.pack_weather
.get_or_insert_with(|| pack_weather::PackWeather::new(&self.device))
.prepare(
&self.device,
&mut encoder,
&mut self.uploads,
atlas,
frame.weather,
frame.weather_time,
&self.indices,
),
_ => None,
};
post.set_weather(weather);
let flat = self.flat.as_ref().filter(|_| flat_frame);
let pages: Vec<(u32, wgpu::Buffer)> = match flat {
Some(_) if pack_draws_terrain || pack_draws_shadow => self
.arena
.page_buffers()
.map(|(p, b)| (p, b.clone()))
.collect(),
_ => Vec::new(),
};
let mut terrain =
|rp: &mut wgpu::RenderPass<'_>,
list: pack_post::TerrainList,
page: &dyn Fn(&mut wgpu::RenderPass<'_>, u32)| {
use pack_post::TerrainList as L;
let shadow = matches!(list, L::ShadowSolid | L::ShadowTranslucent);
if !(if shadow {
pack_draws_shadow
} else {
pack_draws_terrain
}) {
return;
}
match flat {
Some(flat) => {
let [shadow, shadow_translucent] = flat.shadow_calls;
let calls = match list {
L::Solid => &flat.calls[..flat.solid_calls],
L::Translucent => flat.translucent_calls(),
L::ShadowSolid => &flat.calls[shadow..shadow_translucent],
L::ShadowTranslucent => &flat.calls[shadow_translucent..],
};
flat.draw_with(rp, calls, |p| self.arena.buffer(p), page);
}
None => {
rp.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
rp.set_vertex_buffer(1, self.instances.slice(..));
let (first, count) = match list {
L::Solid => (0, solid_draws),
L::Translucent => (solid_draws, translucent_draws),
L::ShadowSolid => (shadow_first, shadow_solid),
L::ShadowTranslucent => {
(shadow_first + shadow_solid, shadow_translucent)
}
};
self.draw_sections(rp, &self.indirect, 0, first, count);
}
}
};
post.set_clouds(pack_draws_clouds.then(|| pack_post::Clouds {
vertices: self.cloud_buffer.clone(),
group: self.cloud_group.clone(),
indices: self.indices.clone(),
quads: cloud_quads,
}));
post.run(
&self.device,
&self.queue,
@@ -4039,11 +4499,28 @@ impl Renderer {
&frame.pack,
size,
[self.config.width, self.config.height],
&pages,
&mut terrain,
);
self.pack_post = Some((dimension, Some(post)));
} else if let Some((color, depth)) = &scaled
&& native_post
&& let Some(post) = &mut self.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,
);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
}
if hand_quads > 0 {
if hand_quads > 0 && !pack_draws_hand {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
@@ -4211,6 +4688,9 @@ impl Renderer {
timer.submitted(&self.queue);
timer.collect(&self.device, &mut self.gpu_times);
}
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native_post) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
@@ -4248,6 +4728,7 @@ impl Renderer {
let _ = self.started;
self.visible = visible;
self.visible_lods = visible_lods;
self.shadow_visible = shadow_visible;
Ok(true)
}
}
+16 -4
View File
@@ -65,8 +65,15 @@ pub struct Arena {
}
impl Arena {
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
/// Vertices; `storage` also lets shaders read them as a storage buffer (a shader pack's
/// terrain program reads the other corners of a vertex's quad for its normal).
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64, storage: bool) -> Self {
let usage = if storage {
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE
} else {
wgpu::BufferUsages::VERTEX
};
Self::with_usage(device, stride, capacity, usage)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -148,6 +155,11 @@ impl Arena {
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
/// Each live page's buffer, by page.
pub fn page_buffers(&self) -> impl Iterator<Item = (u32, &wgpu::Buffer)> {
let pages = self.pages.iter().enumerate();
pages.filter_map(|(i, p)| Some((i as u32, &p.as_ref()?.buffer)))
}
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
@@ -352,7 +364,7 @@ mod tests {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN);
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 2 * GRAIN;
assert_eq!(arena.bytes(), 0);
let (first, a) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
@@ -395,7 +407,7 @@ mod tests {
return;
};
for mut arena in [
Arena::new(&device, 28, GRAIN),
Arena::new(&device, 28, GRAIN, false),
Arena::indices(&device, GRAIN, false),
] {
alloc(&mut arena, &device, &queue, GRAIN).unwrap();
+80 -5
View File
@@ -40,6 +40,8 @@ enum Source {
struct Plan {
phase1_calls: usize,
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -73,7 +75,8 @@ pub struct Flat {
phase2_call_data: Vec<[u32; 5]>,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
draw_layout: wgpu::BindGroupLayout,
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
@@ -96,6 +99,10 @@ pub struct Flat {
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
pub shadow_calls: [usize; 2],
/// Frames with fewer sections in view keep the per-section draws.
pub min_sections: usize,
range_align: u64,
@@ -163,12 +170,16 @@ fn buffer(
/// draw calls, one per run of draws in the same vertex page, phase 1's solid
/// ones, phase 2's (draws `phase1_draws..solid_draws`) and translucent ones
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
/// instead of a destination: the call's room holds all of its draws.
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own.
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
pages: &[(u32, Option<u32>)],
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
@@ -178,6 +189,7 @@ fn plan(
ranges.clear();
segments.clear();
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut total = 0u64;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
@@ -187,10 +199,14 @@ fn plan(
if i == solid_draws {
solid_calls = Some(calls.len());
}
match calls.last_mut() {
Some(last) if last.page == page && i != phase1_draws && i != solid_draws => {
last.count += d[0]
for (k, &from) in shadow.iter().enumerate() {
if i == from {
shadow_calls[k].get_or_insert(calls.len());
}
}
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
page,
first: total as u32,
@@ -226,6 +242,7 @@ fn plan(
Plan {
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
total,
}
}
@@ -339,6 +356,7 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
@@ -411,6 +429,7 @@ impl Flat {
///
/// When culling, the solid draws from `phase1_draws` are phase 2's, which
/// `phase2` holds as the test leaves them: `flatten_phase2` copies them.
/// Draws from `shadow` on are a shader pack's shadow camera's (`plan`).
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
@@ -421,6 +440,7 @@ impl Flat {
pages: &[(u32, Option<u32>)],
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
@@ -463,12 +483,14 @@ impl Flat {
let Plan {
phase1_calls,
solid_calls,
shadow_calls,
total,
} = plan(
draws,
pages,
phase1_draws,
solid_draws,
shadow,
self.range_align,
&mut self.calls,
&mut self.range_data,
@@ -476,10 +498,12 @@ impl Flat {
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
self.shadow_calls = shadow_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -643,6 +667,11 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// The camera's translucent calls.
pub fn translucent_calls(&self) -> &[Call] {
&self.calls[self.solid_calls..self.shadow_calls[0]]
}
/// Draws `calls` with a flat pipeline set and `vertices` giving each
/// page's vertex buffer.
pub fn draw<'a>(
@@ -650,10 +679,22 @@ impl Flat {
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
/// `draw`, calling `page` before each run of calls from one arena page (a shader pack binds
/// that page's vertices for reading).
pub fn draw_with<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for call in calls {
self.bind_page(pass, call.page, &vertices);
page(pass, call.page);
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
@@ -722,11 +763,13 @@ mod tests {
phase1_calls,
solid_calls,
total,
..
} = plan(
&draws,
&pages,
solid,
solid,
[draws.len(); 2],
4,
&mut calls,
&mut ranges,
@@ -761,6 +804,35 @@ mod tests {
assert_eq!(ranges.len(), 12);
}
/// A shader pack's shadow draws follow the camera's, in calls of their
/// own even from the same page: solid ones, then translucent ones.
#[test]
fn plan_gives_the_shadow_lists_calls_of_their_own() {
let (mut draws, mut pages, solid) = sample();
let camera = draws.len();
draws.extend([[6, 1, 0, 40, 6], [6 * 2, 1, 0, 64, 7], [6, 1, 6, 80, 7]]);
pages.extend([(0, None), (0, None), (0, Some(0))]);
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
solid,
solid,
[camera, camera + 2],
4,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
&calls[3..],
[call(0, 60, 18), call(0, 78, 18), call(0, 96, 6)]
);
assert_eq!(plan.total, 6 * 17);
}
/// Phase 2's draws (here the third and fourth) get calls of their own,
/// each with room for all of its draws, and ranges naming their draw
/// and call in phase 2.
@@ -773,6 +845,7 @@ mod tests {
&pages,
2,
solid,
[draws.len(); 2],
4,
&mut calls,
&mut ranges,
@@ -783,6 +856,7 @@ mod tests {
Plan {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
total: 6 * 13
}
);
@@ -886,6 +960,7 @@ mod tests {
&pages,
phase1,
solid,
[draws.len(); 2],
align,
&mut calls,
&mut ranges,
@@ -0,0 +1,520 @@
//! The post stage of Horizon's own shader packs (docs/shaders.md): each `[[post]]` pass is a
//! full-screen pipeline built from its checked naga module (`horizon_shader_api::check`), with
//! 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.
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
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>,
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
}
}
impl NativePost {
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
) -> 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,
},
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack post"),
bind_group_layouts: &groups,
..Default::default()
});
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 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(stage.module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_vs_post"),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
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),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Ok(Self {
passes,
layout,
frame,
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,
})
}
/// The passes' labels, in order.
pub fn labels(&self) -> Vec<&str> {
self.passes.iter().map(|(l, _)| l.as_str()).collect()
}
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
self.targets = Some((
size,
[
target("Native shader pack A"),
target("Native shader pack B"),
],
));
self.groups = None;
}
if self
.groups
.as_ref()
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
{
return;
}
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
_ => world.0,
};
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
/// `screen`.
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],
) {
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);
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
}
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection =
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
// angle, of straight up): overhead at 0, rising in the east (+x).
let body = |angle: f32| {
let a = angle.to_radians();
Vec3::new(-a.sin(), a.cos(), 0.0)
};
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => (
[f.color[0], f.color[1], f.color[2]],
f.environmental_start.min(f.render_distance_start),
f.environmental_end.min(f.render_distance_end),
),
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let pack = &frame.pack;
let level = |l: i32| l as f32 / 15.0;
let m = |m: Mat4| m.to_cols_array_2d();
FrameInputs {
view: m(view),
projection: m(projection),
view_inverse: m(view.inverse()),
projection_inverse: m(projection.inverse()),
camera_position: frame.camera.as_vec3().to_array(),
time: seconds,
sun_direction: body(frame.sky.sun_angle).to_array(),
day_time: pack.day_time.rem_euclid(24000) as f32 / 24000.0,
moon_direction: body(frame.sky.moon_angle).to_array(),
game_time: frame.shader_game_time,
sky_color: frame.sky.sky_color,
rain: pack.rain,
fog_color,
thunder: pack.thunder,
resolution: [size[0] as f32, size[1] as f32],
eye_light: [level(pack.eye_light[0]), level(pack.eye_light[1])],
fog_start,
fog_end,
render_distance: frame.distance,
far: frame.depth_far,
night_vision: frame.night_vision,
blindness: pack.blindness,
darkness: frame.darkness.1.clamp(0.0, 1.0),
brightness: pack.brightness,
eye_in: pack.eye_in as u32,
dimension: if frame.end_sky {
1
} else if frame.nether_lighting {
-1
} else {
0
},
frame_index: index,
hide_gui: u32::from(pack.hide_gui),
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn texture(
device: &wgpu::Device,
format: wgpu::TextureFormat,
usage: wgpu::TextureUsages,
) -> wgpu::Texture {
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: &[],
})
}
/// Two passes: the first doubles the world's colour (over 1, so the half-float target
/// must keep it) times an option, the second halves it and writes the depth as alpha.
#[test]
fn passes_chain_through_half_float_targets_with_options_and_depth() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n\
[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\ndefault = 0.0\nmin = 0.0\nmax = 2.0\n";
let a = "override gain: f32;\nfn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 2.0 * gain, 1.0); }\n";
let b = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, input.depth); }\n";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.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();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.8,
g: 0.4,
b: 0.2,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.5),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
let view = screen.create_view(&Default::default());
post.draw(
&device,
&queue,
&mut encoder,
(&color, &depth),
&view,
&FrameInputs::default(),
[4, 4],
);
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(
screen.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),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
assert!(
near(pixel[0], 204) && near(pixel[1], 102) && near(pixel[2], 51) && near(pixel[3], 128),
"{pixel:?}"
);
}
}
+106 -5
View File
@@ -3,7 +3,6 @@
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")]
pub(super) struct PackAtlas {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
@@ -64,10 +63,6 @@ impl PackAtlas {
}
/// The atlas's size in pixels (`atlasSize`).
#[cfg_attr(
not(test),
expect(dead_code, reason = "the gbuffers pass reads it (next slice)")
)]
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
}
@@ -118,6 +113,112 @@ impl PackAtlas {
}
}
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
vec3 hz_e1 = hz_p1 - hz_p0;\n \
vec3 hz_e2 = hz_p2 - hz_p0;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
if (dot(hz_n, hz_n) > 1e-12) {\n \
hz_Normal = normalize(hz_n);\n \
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
if (dot(hz_t, hz_t) > 1e-12) {\n \
hz_t = normalize(hz_t - hz_Normal * dot(hz_Normal, hz_t));\n \
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
}\n \
}\n";
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
// and the arena page's vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
};
let [width, height] = self.size();
let scale = [
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
);
let mut body = body;
if flat {
body.push_str(FACE);
}
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
let entity = if flat {
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
let attributes = [
("mc_Entity", entity),
(
"mc_midTexCoord",
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
),
("at_tangent", "hz_tangent"),
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_MultiTexCoord0.xy"),
("vaUV2", "hz_MultiTexCoord1.xy"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
alpha_test: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+152
View File
@@ -0,0 +1,152 @@
//! The uniforms a shader pack's gbuffers programs read per draw (`HzDraw` at set 2, the
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
//! dynamic offset at each draw.
use horizon_shaders::{RENDER_STAGES, render_stage};
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
/// none; Horizon itself still draws the whole list at once.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EntitySpan {
/// The first vertex.
pub start: u32,
/// The render stage (5.4: `ENTITIES`, `BLOCK_ENTITIES`, `PARTICLES`, `DESTROY`), which picks
/// the program.
pub stage: u32,
/// `entityId` for entities, `blockEntityId` for block entities (5.3), else 0.
pub id: i32,
/// An entity part that blends (a name tag, a shadow): `gbuffers_entities_translucent`.
pub translucent: bool,
/// Hidden from the camera (Horizon's entity culling, the player in first person): only the
/// shadow pass draws it.
pub hidden: bool,
/// The local player (`shadowPlayer`).
pub player: bool,
/// A block entity whose block emits light (`shadowLightBlockEntities`).
pub emissive: bool,
}
impl EntitySpan {
/// Marks vertices `start..end` of `spans`' list (one entity) as hidden: the span covering
/// `start` is split there, the spans inside are hidden, and the geometry after `end` goes
/// on in the last span's kind. False when no span covers `start`.
pub fn hide(spans: &mut Vec<EntitySpan>, start: usize, end: usize) -> bool {
let Some(i) = spans.iter().rposition(|s| s.start as usize <= start) else {
return false;
};
let after = spans[spans.len() - 1];
if spans[i].start as usize != start {
let split = EntitySpan {
start: start as u32,
..spans[i]
};
spans.insert(i + 1, split);
}
for s in spans.iter_mut().skip(i) {
if s.start as usize >= start {
s.hidden = true;
}
}
if end > start {
spans.push(EntitySpan {
start: end as u32,
..after
});
}
true
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
stride: u32,
}
impl DrawUniforms {
pub fn new(device: &wgpu::Device) -> Self {
let stride = SIZE.max(device.limits().min_uniform_buffer_offset_alignment);
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack draws"),
contents: &contents,
usage: wgpu::BufferUsages::UNIFORM,
},
);
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack draws"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(u64::from(SIZE)),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack draws"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: wgpu::BufferSize::new(u64::from(SIZE)),
}),
}],
});
Self {
layout,
group,
stride,
}
}
/// Draws that follow read `renderStage` = `MC_RENDER_STAGE_<stage>`, other values zero.
pub fn bind(&self, pass: &mut wgpu::RenderPass<'_>, stage: &str) {
pass.set_bind_group(2, &self.group, &[self.stride * render_stage(stage)]);
}
}
#[cfg(test)]
mod tests {
use super::EntitySpan;
fn span(start: u32, id: i32) -> EntitySpan {
EntitySpan {
start,
id,
..Default::default()
}
}
#[test]
fn hide_splits_the_span_around_one_entity() {
// Two cows in one span: the second (vertices 8..16) is hidden.
let mut spans = vec![span(0, 7)];
assert!(EntitySpan::hide(&mut spans, 8, 16));
let hidden = EntitySpan {
hidden: true,
..span(8, 7)
};
assert_eq!(spans, [span(0, 7), hidden, span(16, 7)]);
// An entity with its own span and a part after it (armour) is hidden whole.
let mut spans = vec![span(0, 1), span(4, 2), span(6, 3)];
assert!(EntitySpan::hide(&mut spans, 4, 10));
assert!(!spans[0].hidden && spans[1].hidden && spans[2].hidden);
assert_eq!(spans[3], span(10, 3));
assert!(!EntitySpan::hide(&mut Vec::new(), 0, 4));
}
}
@@ -0,0 +1,524 @@
//! Entities, block entities, translucent entity parts and particles drawn by a shader pack's
//! `gbuffers_entities`, `gbuffers_block`, `gbuffers_entities_translucent` and
//! `gbuffers_particles` (spec 2.3, 5.3, 5.4): the runs of `Frame::entities` the game marked (`EntitySpan`), each its
//! own draw with its `entityId` or `blockEntityId` and `entityColor` in a per-draw slot of
//! `HzDraw` (set 2). Horizon draws whatever the pack does not.
use std::ops::Range;
use horizon_shaders::render_stage;
use super::Vertex;
use super::pack_draw::EntitySpan;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
const ATTRIBUTES: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4, 4 => Uint8x4, 5 => Snorm8x4
];
/// The entity vertex: Horizon's own (`Vertex`).
pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRIBUTES,
})]
}
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
vec4 hz_tangent; vec2 hz_mid;\n"
.to_owned();
let words = std::mem::size_of::<Vertex>() / 4;
let corner = |k: usize, field: &str| {
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
match field {
"pos" => format!("uintBitsToFloat(uvec3({}, {}, {}))", at(0), at(1), at(2)),
_ => format!("uintBitsToFloat(uvec2({}, {}))", at(3), at(4)),
}
};
let body = format!(
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
vec3 hz_e1 = {p1} - {p0};\n \
vec3 hz_e2 = {p2} - {p0};\n \
vec2 hz_t0 = {t0};\n \
vec2 hz_d1 = {t1} - hz_t0;\n \
vec2 hz_d2 = {t2} - hz_t0;\n \
hz_mid = (hz_t0 + {t2}) * 0.5;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz)\n \
: dot(hz_n, hz_n) > 1e-12 ? normalize(hz_n) : vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n \
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
hz_t -= hz_Normal * dot(hz_Normal, hz_t);\n \
if (dot(hz_t, hz_t) > 1e-12) {{\n \
hz_t = normalize(hz_t);\n \
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
}}\n \
hz_Vertex = vec4(hz_v_pos, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n",
p0 = corner(0, "pos"),
p1 = corner(1, "pos"),
p2 = corner(2, "pos"),
t0 = corner(0, "uv"),
t1 = corner(1, "uv"),
t2 = corner(2, "uv"),
);
let attributes = [
("at_tangent", "hz_tangent"),
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV1", "ivec2(0, 10)"),
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
alpha_test: Some(horizon_shaders::AlphaTest {
op: ">",
reference: 0.1,
}),
}
}
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) uniform HzCloud { vec4 hz_cloud_offset; vec4 hz_cloud_colors[4]; };\n"
.to_owned();
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
.to_owned();
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV2", "ivec2(0, 240)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
alpha_test: None,
}
}
/// `entityColor` for a run whose first quad carries overlay code `overlay`: the hurt red or a
/// white flash, its alpha how much of that colour shows over the entity (vanilla mixes the
/// overlay texel's colour in by one minus the texel's alpha).
pub fn entity_color(overlay: u32) -> [f32; 4] {
let [r, g, b, a] = crate::overlay::HURT_TEXEL.map(|v| f32::from(v) / 255.0);
let white = |u: u32| {
let a = f32::from(crate::overlay::white_alpha(u)) / 255.0;
[1.0, 1.0, 1.0, 1.0 - a]
};
match overlay {
1 | 8 => [r, g, b, 1.0 - a],
5 => white(15),
16..32 => white(overlay - crate::overlay::WHITE),
_ => [0.0; 4],
}
}
/// One draw: its quads in `PackEntities`' buffer, its program and its slot of `HzDraw`.
pub(super) struct Draw {
/// The first vertex.
pub base: u32,
pub quads: u32,
/// The kind of program that draws it (`ENTITY`, `BLOCK`, `TRANSLUCENT`, `PARTICLE`).
pub program: usize,
/// The pack's gbuffers program draws it (else Horizon does).
pub pack: bool,
/// The pack's shadow program draws it into the shadow map too.
pub shadow: bool,
pub slot: u32,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Casters {
/// `shadow_entities` (or `shadow`) draws entities...
pub entities: bool,
/// ...only the local player (`shadowPlayer` without `shadowEntities`).
pub only_player: bool,
/// `shadow_block` (or `shadow`) draws block entities...
pub blocks: bool,
/// ...only those whose block emits light (`shadowLightBlockEntities`).
pub only_lit: bool,
}
impl Casters {
/// Whether the shadow pass draws `span`'s run, of kind `program`.
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
match program {
ENTITY => self.entities && (!self.only_player || span.player),
BLOCK => self.blocks && (!self.only_lit || span.emissive),
_ => false,
}
}
}
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
pub(super) struct PackEntities {
vertices: wgpu::Buffer,
/// Set 1: the vertices again, for the quads' corners.
pub table: wgpu::BindGroupLayout,
pub table_group: wgpu::BindGroup,
slots: wgpu::Buffer,
pub group: wgpu::BindGroup,
stride: u64,
/// Two triangles per quad, for `quads` quads.
pub indices: wgpu::Buffer,
quads: u32,
scratch: Vec<Vertex>,
uniforms: Vec<u8>,
pub draws: Vec<Draw>,
}
impl PackEntities {
pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self {
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack entity vertices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
Self {
vertices,
table,
table_group,
slots,
group,
stride,
indices: quad_indices(device, 1024),
quads: 1024,
scratch: Vec::new(),
uniforms: Vec::new(),
draws: Vec::new(),
}
}
fn vertex_buffer(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity vertices"),
size: vertices * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity vertices"),
layout: table,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, group)
}
fn slot_buffer(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
slots: u64,
stride: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity draws"),
size: slots * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity draws"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
(buffer, group)
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
/// The dynamic offset of draw slot `slot`.
pub fn offset(&self, slot: u32) -> u32 {
(u64::from(slot) * self.stride) as u32
}
/// Splits `entities` between the pack, which draws the runs of `spans` its programs cover
/// (`drawn`: whether it has `gbuffers_entities`, `gbuffers_block` or their fallbacks), and
/// Horizon, which draws the rest (`rest`). Uploads the pack's part, and the runs its shadow
/// programs draw (`shadow`). Runs hidden from the camera only ever cast shadows.
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|_| plain(run));
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
rest.extend_from_slice(run);
}
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
continue;
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: run.len() as u32 / 4,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
}
if self.draws.is_empty() {
return;
}
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
(self.vertices, self.table_group) =
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
}
if need_slots > have_slots {
(self.slots, self.group) =
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
}
let quads = self.draws.iter().map(|d| d.quads).max().unwrap_or(0);
if quads > self.quads {
self.quads = quads.next_power_of_two();
self.indices = quad_indices(device, self.quads);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
/// Two triangles for each of `quads` quads, from vertex 0.
pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
.collect();
wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack entity indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
},
)
}
/// Whether a run holds only geometry a gbuffers program can draw from the shared atlas: no
/// celestial body, cloud, beam, End portal or crack (overlay codes Horizon's entity shader
/// draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12))
}
/// The programs of entity runs, in the order they draw.
pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const PROGRAMS: usize = 4;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
#[cfg(test)]
fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
program_of(span).filter(|&p| drawn[p])
}
/// The kind of program that draws `span`'s run, when a pack may.
pub fn program_of(span: &EntitySpan) -> Option<usize> {
Some(if span.stage == render_stage("ENTITIES") {
if span.translucent {
TRANSLUCENT
} else {
ENTITY
}
} else if span.stage == render_stage("BLOCK_ENTITIES") {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else {
return None;
})
}
/// Each span with its vertex range: from its start to the next span's (or the end).
pub fn runs(
len: usize,
spans: &[EntitySpan],
) -> impl Iterator<Item = (EntitySpan, Range<usize>)> + '_ {
spans.iter().enumerate().map(move |(i, s)| {
let end = spans.get(i + 1).map_or(len, |n| n.start as usize);
let start = (s.start as usize).min(len);
(*s, start..end.clamp(start, len))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
};
let span = |start, stage: &str, id| EntitySpan {
start,
stage: render_stage(stage),
id,
..Default::default()
};
let spans = [
span(2, "ENTITIES", 7),
span(5, "PARTICLES", 0),
span(6, "BLOCK_ENTITIES", 9),
];
let ranges: Vec<_> = runs(10, &spans).map(|(_, r)| r).collect();
assert_eq!(ranges, [2..5, 5..6, 6..10]);
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
assert_eq!(entity_color(1)[0], 1.0);
assert!((entity_color(1)[3] - (1.0 - 178.0 / 255.0)).abs() < 1e-6);
assert_eq!(entity_color(0), [0.0; 4]);
// The shadow pass: entities, the player alone, block entities, those that emit light.
let all = Casters {
entities: true,
blocks: true,
..Default::default()
};
let player = EntitySpan {
player: true,
..spans[0]
};
let lit = EntitySpan {
emissive: true,
..spans[2]
};
assert!(all.cast(&spans[0], ENTITY) && all.cast(&spans[2], BLOCK));
assert!(!all.cast(&spans[1], PARTICLE));
let some = Casters {
only_player: true,
only_lit: true,
..all
};
assert!(!some.cast(&spans[0], ENTITY) && some.cast(&player, ENTITY));
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! The first-person hand and held items drawn by a shader pack's `gbuffers_hand` (spec 2.3):
//! `Frame::hand`'s entity vertices, read as entities are (`pack_entities::inputs`), with the
//! hand's own projection as `gl_ProjectionMatrix` (5.2: `gbufferProjection` stays the world's).
use glam::Mat4;
use super::Vertex;
/// `MC_HAND_DEPTH`'s value (1.5): the hand's depth is scaled by it through its projection, so
/// that it sits in front of the world in the shared depth buffer.
const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
inputs.body.push_str(
" hz_ProjectionMatrix = hz_hand_projection;\n \
hz_ProjectionMatrixInverse = hz_hand_projection_inverse;\n \
hz_ModelViewProjectionMatrix = hz_hand_projection * hz_ModelViewMatrix;\n",
);
inputs
}
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
/// `MC_HAND_DEPTH`.
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
* bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
/// indices.
pub struct PackHand {
pub layout: wgpu::BindGroupLayout,
vertices: wgpu::Buffer,
uniform: wgpu::Buffer,
pub group: wgpu::BindGroup,
pub indices: wgpu::Buffer,
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack hand"),
entries: &[
entry(0, wgpu::BufferBindingType::Storage { read_only: true }),
entry(1, wgpu::BufferBindingType::Uniform),
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand"),
size: UNIFORM,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
vertices,
uniform,
group,
capacity,
quads: 0,
}
}
fn vertex_buffer(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
uniform: &wgpu::Buffer,
quads: u32,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand vertices"),
size: u64::from(quads) * 4 * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: uniform.as_entire_binding(),
},
],
});
(buffer, group)
}
/// Uploads this frame's hand and its projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
if self.quads == 0 {
return;
}
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
self.indices = super::pack_entities::quad_indices(device, self.capacity);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
let (a, b) = (p * v, plain * v);
let window = (a.z / a.w) * 0.5 + 0.5;
assert!(
(window - 0.5).abs() <= HAND_DEPTH * 0.5 + 1e-6,
"{z}: {window}"
);
assert!((a.x / a.w - b.x / b.w).abs() < 1e-6);
}
}
}
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -1,5 +1,6 @@
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
// depthtex0, and colortex0 onto the screen for packs without `final`.
// depthtex0 (a depth attachment, for terrain's depth test), and colortex0 onto the screen for
// packs without `final`.
@vertex
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
@@ -12,7 +13,7 @@ fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
struct World {
@location(0) color: vec4<f32>,
@location(1) depth: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
@@ -23,7 +24,7 @@ fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = vec4<f32>(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0);
out.depth = 1.0 - textureLoad(world_depth, at, 0);
return out;
}
@@ -52,3 +53,11 @@ fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
}
// One level of a buffer's mip chain from the level above (6.1.7): the average of the 2×2
// texels under each destination texel, by one bilinear sample at their shared corner.
@fragment
fn fs_down(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let size = max(textureDimensions(source) / 2u, vec2<u32>(1u));
return textureSampleLevel(source, linear, p.xy / vec2<f32>(size), 0.0);
}
@@ -0,0 +1,208 @@
//! A shader pack's shadow buffers (spec 6.3): the shadow map's depth (`shadowtex0`, and
//! `shadowtex1` without translucent geometry), and the colour buffers `shadowcolor0/1`, each
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
/// Loaded and stored.
pub(super) fn load<T>() -> Ops<T> {
wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}
}
fn clear(c: [f32; 4]) -> Ops<Color> {
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: c[0].into(),
g: c[1].into(),
b: c[2].into(),
a: c[3].into(),
}),
store: wgpu::StoreOp::Store,
}
}
/// What the shadow pass writes and the programs read.
pub(super) struct ShadowTargets {
pub size: u32,
/// `shadowtex0`, written by the shadow pass.
pub depth: (wgpu::Texture, wgpu::TextureView),
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides of each shadowcolor buffer used.
pub color: [Option<[wgpu::TextureView; 2]>; 2],
/// The clear colour of each buffer cleared every frame.
pub clears: [Option<[f32; 4]>; 2],
/// Bit n set: B is shadowcolor n's "main".
pub main: u16,
/// Nothing cleared yet this frame: the first pass into the shadow buffers clears them as it
/// begins (`clear_ops`), which saves a pass that loads and stores them all on tiled GPUs.
fresh: bool,
/// Bit n set: shadowcolor n was cleared that way.
cleared: u8,
/// Depth comparison for `sampler2DShadow` (hardware filtering): linear, as GL filters it.
pub comparison: wgpu::Sampler,
}
fn target(
device: &wgpu::Device,
label: &str,
size: u32,
format: wgpu::TextureFormat,
copy: wgpu::TextureUsages,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | copy,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; 2],
colors: u16,
depth1: bool,
) -> Self {
let size = shadow
.resolution
.clamp(1, device.limits().max_texture_dimension_2d);
let copy = wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
let depth = wgpu::TextureFormat::Depth32Float;
let color = std::array::from_fn(|n| {
(colors & 1 << n != 0).then(|| {
[0, 1].map(|side| {
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
target(
device,
&label,
size,
formats[n],
wgpu::TextureUsages::empty(),
)
.1
})
})
});
Self {
size,
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
color,
clears: std::array::from_fn(|n| {
let b = &shadow.colors[n];
b.clear.then(|| b.clear_color.unwrap_or([1.0; 4]))
}),
main: 0,
fresh: false,
cleared: 0,
comparison: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
}
}
/// Side `alt` (of "main" and "alt") of shadowcolor `n`.
pub fn color(&self, n: usize, alt: bool) -> Option<&wgpu::TextureView> {
let side = usize::from(self.main & 1 << n != 0) ^ usize::from(alt);
self.color.get(n)?.as_ref().map(|c| &c[side])
}
/// A new frame: the shadow map and the shadowcolor buffers cleared every frame (6.3) are
/// cleared by the first pass that begins (`clear_ops`), or else by `clear_rest`.
pub fn begin_frame(&mut self) {
self.fresh = true;
self.cleared = 0;
}
/// The load operations of a pass into the shadow map and shadowcolor buffers `writes`:
/// clears in the frame's first one, loads after.
pub fn clear_ops(
&mut self,
writes: impl Iterator<Item = usize>,
) -> (Ops<f32>, [Ops<Color>; 2]) {
if !std::mem::take(&mut self.fresh) {
return (load(), [load(); 2]);
}
let mut colors = [load(); 2];
for n in writes.filter(|&n| n < 2) {
if let Some(c) = self.clears[n] {
colors[n] = clear(c);
self.cleared |= 1 << n;
}
}
let depth = wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
};
(depth, colors)
}
/// Clears what no pass has cleared this frame: all of it when no shadow program drew, the
/// cleared shadowcolor buffers the first one did not write.
pub fn clear_rest(&mut self, encoder: &mut wgpu::CommandEncoder) {
let depth = std::mem::take(&mut self.fresh);
let cleared = std::mem::replace(&mut self.cleared, 0b11);
let attachments: Vec<_> = (0..2)
.filter(|n| cleared & 1 << n == 0)
.filter_map(|n| {
Some(Some(wgpu::RenderPassColorAttachment {
view: self.color(n, false)?,
depth_slice: None,
resolve_target: None,
ops: clear(self.clears[n]?),
}))
})
.collect();
if !depth && attachments.is_empty() {
return;
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack shadow clear"),
color_attachments: &attachments,
depth_stencil_attachment: depth.then_some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth.1,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
/// `shadowtex1`: the shadow map as it is now (before translucent geometry).
pub fn copy_depth1(&self, encoder: &mut wgpu::CommandEncoder) {
if let Some((copy, _)) = &self.depth1 {
encoder.copy_texture_to_texture(
self.depth.0.as_image_copy(),
copy.as_image_copy(),
self.depth.0.size(),
);
}
}
}
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more