Compare commits

..
Author SHA1 Message Date
Ludovic Marie b3149d43ef Merge pull request #1677 from sdraeger/feat/mod-command-capability
🤖 Agent: Mod API capability `command` and the Predator Missile example, by @sdraeger. Thank you! Main merged in, with `command` kept beside `ui` as a 0.1 preview interface. Verified: pr_ready (2106 workspace tests), and in game in-frame and on the server thread, including survival without cheats refused.
2026-10-26 01:19:35 +01:00
Ludovic Marie e1a7892361 Merge pull request #1985 from ludovic111/perf/parity-disk
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:52:57 +01:00
Ludovic Marie e9f93c656a Merge pull request #1981 from ludovic111/ci/parity-main
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:48:22 +01:00
Ludovic MarieandClaude Opus 5.5 eec2e87a15 Merge main: command beside main's ui capability
Conflicts kept both sides: the capability list (now 7), the WIT, the
SDK's re-exports and docs, the mods state's fields, the workspace
members and AGENTS.md's macOS --exclude list. The API reference is
regenerated, and docs/api/stability.md lists `command` with the 0.1
preview interfaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:48:02 +01:00
Ludovic Marie 373d9c08c0 Merge pull request #1947 from ludovic111/feat/prepare-output-check
🤖 Agent: verified: OUTPUT_VERSION = 1 and output-versions.txt carry the digest measured on 10 CI preparations. tools/prepare_output.py check passes on v0.2.64's preparation and fails on v0.2.47's. This head's own parity job prepares with its preparer and checks the digest. Merging.
2026-10-26 00:47:05 +01:00
Ludovic Marie 911ca721de Merge pull request #1983 from ludovic111/perf/cover-test-debug
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:46:46 +01:00
Ludovic MarieandClaude Opus 5.5 5034e4557d parity: clear rust-<name>-parity outputs after a pass, reuse oracles on CI
Disk: clear_large_outputs only looked at .local directories named after the
harness, so the 174 harnesses writing to rust-<name>-parity kept every dump
(86 GB in one runner checkout, 13 GB for state_provider alone). It now
clears those too, keeps vanilla outputs only up to 256 MB (larger ones cost
more disk than rerunning their oracle), and handles shard names.
collision, world and geometry dropped one mkdtemp run directory per run
(11 GB): each run now removes the earlier ones.

Time: harnesses inherited the runner's stdin, a pipe on GitHub's runner, and
parity_shim never reuses an oracle run whose stdin is a pipe, so oracle reuse
never worked on CI. Harnesses now get /dev/null.

CI: parity.yml falls back to the head's own selection when the names main's
`--list` printed don't match its summary count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:44:22 +01:00
Ludovic MarieandClaude Opus 5.5 40c08c2d12 streaming: check every fourth cell of the cover test in debug builds
the_grid_answers_as_the_scan compared the grid with the scan on every cell of
60 cases and took 20 of the ~30 s of `cargo test -p horizon-client` on the
ThinkPad. A debug build now checks every fourth cell of each row, shifted by
row and case, so the cases together still meet every cell position relative
to the areas; a release build checks every cell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:38:47 +01:00
Ludovic MarieandClaude Opus 5.5 73da2f04dd CI: parity on main, a batch of merges at a time; releases wait for it
parity-main runs the vanilla harnesses on every push to main, one run at a
time: merges that land during a run wait in one pending run, which checks
every merge since main's last green parity-main (tools/parity_main.py base,
then parity.py --changed). A failure is bisected over the batch's
first-parent merges, running only the failing harnesses, and the breaking
merge's pull request gets a comment.

Game releases ship only a commit both checks and parity-main passed on
(release_train.py; release.yml also starts when parity-main finishes; a
forced release doesn't wait). The per-PR parity job now runs only for a PR
that changes a harness, an oracle, a probe or the runner, or carries
run-parity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:35:28 +01:00
Ludovic Marie 39efe6ef39 Merge pull request #1968 from ludovic111/ci/parity-select-from-main
🤖 Agent: merged on verify. It changes only which harnesses CI picks: main's item-level selection for every queued branch. I checked on main that the --list format gives the names line by line, then the summary line.
2026-10-26 00:33:51 +01:00
Ludovic Marie 29c09b4bf2 Merge pull request #1794 from ludovic111/perf/paletted-locate-no-div
🤖 Agent: verify and parity pass on 40c77c02f; −12 to −14% per read of 5–7-bit sections (body). Queued by perf-next-2.
2026-10-26 00:32:09 +01:00
Ludovic Marie 2189baea6d Merge pull request #1778 from ludovic111/perf/worker-count
🤖 Agent: verify and parity pass on 1437537cb; spawn load −15% and teleports −12% on a 4-thread laptop (measurements in the body). Queued by perf-next-2.
2026-10-26 00:29:11 +01:00
Ludovic MarieandClaude Opus 5.5 836c1ee3b9 CI parity: pick harnesses with main's selection code, run them at the head
The parity job checks out the PR head, so every branch made before #1964
still selected with the old per-crate rule. The step now runs main's
tools/parity.py and tools/parity_reach.py with --changed --list, restores the
head's files, and runs the named harnesses with the head's parity.py. A
branch that edits either file, or a failure of main's selection, falls back
to the head's own `--changed` run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:27:32 +01:00
Ludovic Marie cc7534341c Merge pull request #1964 from ludovic111/perf/ci-speed-selection
🤖 Agent: merged on verify. It changes only which harnesses run, not the game; evidence in the body: all 61 failed parity runs of 10-18..10-25 are still caught, and LLVM coverage on 6 harnesses confirms the selection. Its own parity selects no harness. CI speed is Ludovic's priority tonight.
2026-10-26 00:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 21d8e53034 parity: pick harnesses by the Rust items their probes reach, not whole crates
`--changed` / `--changed-files` now narrow each harness's recorded Rust
sources to those its probes can reach (tools/parity_reach.py): an item-level
reachability walk from the probe modules a harness ran (or the binary it
built) over free items, inherent methods, trait impls and macros, matched by
name so it only over-approximates. Parent modules count through their `mod`
declarations, compared with the merge base. A harness whose probe can't be
placed keeps every source; `--by-crate` turns the narrowing off.

On the last 20 PRs merged on 2026-10-25: 2255 -> 1061 harnesses picked,
2384 -> 1592 harness-minutes. Replaying the week's 61 failed CI parity runs,
every failed harness the old selection picked is still picked; LLVM coverage of
six harnesses' probes executes only kept files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:14:51 +01:00
Ludovic Marie 6df37324f5 Merge pull request #1853 from ludovic111/fix/mob-ambient-particles
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-26 00:01:39 +01:00
Ludovic Marie d58d26c793 Merge pull request #1798 from socksprox/fix/mob-combat-poses
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 23:10:07 +01:00
Ludovic Marie 93e3096e06 Merge pull request #1875 from kaicsm/fix/client-mods-server-thread
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR) and verify on the head with main merged in. Thank you!
2026-10-25 23:08:25 +01:00
Ludovic Marie e05933169a Merge pull request #1857 from Joansitoh/fix/item-lore-tooltips
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 23:01:30 +01:00
Ludovic Marie bb3497bea0 Merge pull request #1671 from socksprox/fix/elytra-fireworks
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 23:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 6ef8deedca Merge origin/main into fix/mob-combat-poses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:56:32 +01:00
Ludovic MarieandClaude Opus 5.5 d63d0c1744 Merge origin/main into fix/client-mods-server-thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:56:31 +01:00
Ludovic Marie f5e97deac2 Merge pull request #1834 from vlesmentie/fix/lava-cauldron-player
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 22:55:16 +01:00
Ludovic MarieandClaude Opus 5.5 116a7f7dca horizon-prepare: OUTPUT_VERSION, the number of what it prepares, checked by CI
Launchers copy a player's prepared resources to a new release while its preparer declares the
same OUTPUT_VERSION (the launcher side is its own pull request). The number is bumped by hand;
CI keeps it honest: the parity job already prepares with every change to the preparer, and
tools/prepare_output.py check now fails when that preparation's digest is not the one
crates/horizon-prepare/output-versions.txt gives the current number, or when the file changed
other than by appending a version. The digest canonicalises what differs between two runs of
the same preparer (JSON key order, creative tab tag order, lambda class addresses, logs, the
sounds): 10 preparations by 5 preparer versions on the three Mac runners give one digest, and a Linux preparation by an older preparer gives the Mac runners' digest for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:48:51 +01:00
Ludovic MarieandClaude Sonnet 5.5 31e9e2da97 Merge origin/main into fix/item-lore-tooltips
In stack_tooltip keep #1806's translate through `display_get` and this
PR's `push` helper for the styled tooltip lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 22:26:10 +01:00
Ludovic Marie 74768d2eeb Merge pull request #1939 from ludovic111/roadmap/player-ideas-1025
🤖 Agent: merged after verify (roadmap data only).
2026-10-25 22:24:22 +01:00
Ludovic MarieandClaude Opus 5.5 a4a709c2d6 Roadmap: OpenGL shipped in v0.2.63; three players' ideas kept by Ludovic
One-command server install, launcher-style game menus (off by default), AppImage and a Nix flake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:16:18 +01:00
Ludovic Marie b4345b1c2b Merge pull request #1813 from luiS1q1/fix/ios-dualsense-build
🤖 Agent: thank you @luiS1q1! Merged on Ludovic's go-ahead as iOS groundwork (desktop targets unchanged, clippy clean, this head's verify).
2026-10-25 22:16:05 +01:00
Ludovic Marie ed11f614f8 Merge pull request #1870 from ludovic111/fix/complete-sounds-panorama
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 22:13:20 +01:00
Ludovic Marie da0056c248 Merge pull request #1936 from ludovic111/fix/opengl-gl33
🤖 Agent: merged after verify and real GL runs on the ThinkPad (Intel HD 620, GL 3.3 with extensions hidden: title, server thread and in-frame render, 0 errors; screenshots match Vulkan). Released at once: any GL driver without compute shaders crashed at start without it.
2026-10-25 22:10:39 +01:00
Ludovic MarieandClaude Sonnet 5.5 2364bd3504 Merge origin/main into fix/elytra-fireworks
Keep main's item_sync.rs and lan::guest::stack_to_network for stacks on
the wire (#1874) and move the firework component decoding there:
Component::Fireworks and Component::FireworkExplosion in
item_sync::components_tag, with fireworks_tag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 22:04:54 +01:00
Ludovic Marie 82b0051a3c Merge pull request #1856 from Domanffe/fix/furnace-experience
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:53:58 +01:00
Ludovic Marie e0abe90c5c Merge pull request #1858 from Excurs1ons/fix/server-death-messages
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:53:06 +01:00
Ludovic Marie 982034f5f8 Merge pull request #1806 from Excurs1ons/fix/localized-ui-text
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:51:23 +01:00
Ludovic MarieandClaude Opus 5.5 2761349d98 Renderer: no indirect buffer usage without indirect draws (OpenGL before 4.3)
wgpu refuses to create a buffer with INDIRECT usage on a device without
DownlevelFlags::INDIRECT_EXECUTION (OpenGL before 4.3 without compute shaders), so the game
panicked creating 'Section draws'. Multi-draw now also needs indirect execution, and without it
the buffer has no INDIRECT usage: sections are drawn one direct draw each, as they already were
without INDIRECT_FIRST_INSTANCE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:50:49 +01:00
Ludovic MarieandClaude Sonnet 5.5 21d8b0702c Merge origin/main into fix/lava-cauldron-player
Keep both the lava cauldron and wither rose match arms in
MotionWorld's entity-inside handling, and both tests.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 21:50:03 +01:00
Ludovic Marie d0f61d07cf Merge pull request #1879 from socksprox/fix/legacy-colour-codes
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:48:30 +01:00
Ludovic Marie 4e8269276f Merge pull request #1884 from cryingtoilet/fix/1764-creative-search-word-delete
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:47:46 +01:00
Ludovic Marie fd56c1f358 Merge pull request #1850 from rdbtCVS/t3/fix-end-crystal-explosion
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:47:00 +01:00
Ludovic Marie fa2ae64d83 Merge pull request #1874 from Domanffe/fix/creative-item-components
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:45:05 +01:00
Ludovic Marie c5d816cc12 Merge pull request #1920 from ludovic111/fix/opengl-downlevel
🤖 Agent: merged after verify and a real OpenGL run on the desktop (Mesa, GL 4.6 and 3.3: no crash, animated sprites on the CPU below 4.3). Released at once on Ludovic's request.
2026-10-25 21:44:24 +01:00
Ludovic Marie 99a954d665 Merge pull request #1823 from vlesmentie/fix/wither-rose-player
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:43:03 +01:00
Ludovic Marie f3efe2802c Merge pull request #1811 from vlesmentie/fix/zombie-horseman-peaceful
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:42:22 +01:00
Ludovic MarieandClaude Opus 5.5 f65750baed Merge origin/main into fix/opengl-downlevel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:35:35 +01:00
Ludovic MarieandClaude Opus 5.5 21203de7f0 Animated sprites: the CPU path also when the driver cannot build the compute pipeline
An OpenGL driver can list compute shaders among its extensions while wgpu writes GLSL of its core
version, which has none (seen under MESA_GL_VERSION_OVERRIDE=3.3: the pipeline failed and the game
panicked). The pipeline is now created inside error scopes, and a failure falls back to the CPU path
with one warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:33:05 +01:00
Ludovic Marie 731d88a1d8 Merge pull request #1917 from ludovic111/fix/opengl-fallback
🤖 Agent: merged after verify, a real OpenGL run (Mesa GL 4.6: title and a world rendered, no errors) and the backend/shader unit tests. Released at once on Ludovic's request (release-now): a player's Radeon HD 7400M could not start Horizon.
2026-10-25 21:30:01 +01:00
Ludovic MarieandClaude Opus 5.5 1a16e35aaa Animated sprites without compute shaders (OpenGL before 4.3)
SpriteAnimator wrote every changed sprite with a compute pass, which OpenGL 3.3 to 4.2 has no
equivalent for in wgpu: creating the pipeline failed. Without compute shaders and storage, the same
jobs now run on the CPU with animate.wgsl's integer mix, and each level is copied into its layer
through the upload belt. The GPU test runs both paths against the same CPU reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:25:37 +01:00
Ludovic MarieandClaude Opus 5.5 b3e400a526 Renderer: sprite arrays OpenGL can view as arrays
wgpu's GL backend picks a texture's target from its layer count when it creates it: one layer makes
a 2D texture and a multiple of six on a square texture a cube map, and neither can be bound as
terrain.wgsl's texture_2d_array. On GL, the 1-layer stand-in for unused sprite arrays gets two
layers and a sprite array of 1 or 6n layers one empty layer more. Other backends are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:25:37 +01:00
Ludovic MarieandClaude Opus 5.5 903da4f1ab Cargo.lock: wgpu's GL backend dependencies (glow, khronos-egl, glutin_wgl_sys)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:20:02 +01:00
Ludovic MarieandClaude Opus 5.5 54bb3e4fbe Screen effects: one sampler per entry point, so the shader compiles for OpenGL
GLSL combines a texture with its sampler, and naga refuses an entry point that reads one texture
through two (ImageMultipleSamplers): the BLUR override left both branches in fs_screen and
fs_vignette, so creating the screen effect pipelines failed on wgpu's GL backend at startup. Blurred
sprites now have their own entry points (fs_screen_blur, fs_vignette_blur); each pipeline still
samples once per pixel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:19:47 +01:00
Ludovic MarieandClaude Opus 5.5 03a5cbdcd8 Renderer: OpenGL as the last fallback, for GPUs with no Vulkan or DirectX 12 driver
wgpu's GL backend (WGL on Windows, EGL on Linux). Auto tries Vulkan, then DirectX 12 on Windows,
then OpenGL; graphicsApi:opengl (or HORIZON_GRAPHICS_API=opengl) picks it. A Radeon HD 7400M
(TeraScale 2: DirectX 11, OpenGL 4.x) could not start Horizon at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:11:47 +01:00
Ludovic Marie b7b4a26164 Merge pull request #1891 from rdbtCVS/fix/renderer-surface-fallback
🤖 Agent: thank you @rdbtCVS! Merged after review (a surface failure now falls through to the next backend, as adapter and device failures do) and this head's verify. Follow-up: an OpenGL fallback for GPUs with neither Vulkan nor DirectX 12 builds on this.
2026-10-25 21:09:37 +01:00
Ludovic Marie 8b4336013a Merge pull request #1864 from socksprox/feat/mod-ui
🤖 Agent: thank you @socksprox! Merged after review (two follow-up commits on your branch: `ui` is opt-in through `experimental = ["ui"]`, and the Settings button now appears when a mod sets its page after loading), tests in game in-frame, on a server-thread window and as a LAN guest, and this head's verify.
2026-10-25 21:07:26 +01:00
Ludovic Marie 1c3be171ce Merge pull request #1867 from ludovic111/fix/prepare-regression
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:58:37 +01:00
Ludovic MarieandClaude Opus 5.5 befec1995f ui: a Settings button appears when the mod sets its page later
has_settings was read once, when the mods loaded, so a mod that called
set-settings from a command or a tick never got its Settings button
until a reload. The Mods page's rows now follow each mod call's output.
Also clippy with --features mods: needless borrows of the atlas in
draw_mod_screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:57:10 +01:00
Ludovic MarieandClaude Opus 5.5 05396b4edf ui: experimental, opted into with experimental = ["ui"] in the manifest
docs/api/stability.md: a new API starts experimental, usable only by
opting in with a manifest field naming it. Capability ui now needs
experimental = ["ui"] beside capabilities; without it the manifest is
refused with that line to add. A capability promoted later stays
accepted in the list, so such manifests keep loading.

The ui test guest opts in; stability.md gets ui's row; docs/mods.md
shows the key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:57:10 +01:00
Ludovic Marie 3d8a8f7d02 Merge remote-tracking branch 'origin/main' into cg14/pr1671
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
2026-10-25 20:54:03 +01:00
Alexander KrylovandHermes Agent bc09558dfa Merge origin/main into fix/creative-item-components
Main gained PotionContents on the wire (#1796) in the same two functions
this branch hands to the shared codec. Resolved by keeping the codec and
teaching it the potion: components_tag writes it by its built-in registry
name, and the remote's id lookup asks the built-in registries first, as a
hub's SyncData::id already does. The round-trip test now covers a splash
potion.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 22:42:28 +03:00
Ludovic Marie 7fd1f3118f Merge pull request #1848 from kacpherek/fix/1501-shulker-nesting
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:32:18 +01:00
Ludovic Marie 4309d96924 Merge pull request #1845 from kacpherek/fix/1297-server-freeze-damage
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:30:27 +01:00
Ludovic Marie 478b8c19c4 Merge pull request #1844 from kacpherek/fix/1520-ride-mobs
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:29:24 +01:00
Ludovic Marie 2af75ba9f6 Merge pull request #1849 from kacpherek/fix/1329-cursor-death
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:27:19 +01:00
Ludovic Marie 89888e9b07 Merge pull request #1847 from kacpherek/fix/1351-pearl-vanish-on-death
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:25:09 +01:00
Ludovic Marie 1110a28521 Merge pull request #1902 from ludovic111/fix/supervisor-lock-test-race
🤖 Agent: merged after verify passed (test-only fix).
2026-10-25 20:23:30 +01:00
Ludovic Marie f6e451162c Merge pull request #1843 from kacpherek/fix/1504-hardcore-spectator
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:21:24 +01:00
Ludovic Marie 6ce6577886 Merge pull request #1841 from kacpherek/fix/1378-team-chat-names
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:19:46 +01:00
Ludovic Marie 8f2aa7f7e9 Merge pull request #1836 from kacpherek/fix/1483-mob-nbt-attributes
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 20:17:30 +01:00
Ludovic MarieandClaude Opus 5.5 4513af8995 horizon-server: the lock test waits for a fork-held descriptor to close
Tests in the same binary spawn processes (tar, the engine); between fork and exec the child holds a copy
of every open descriptor, the lock's too, so relocking right after drop could see WouldBlock. Seen twice
on CI (#1833, #1895).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:13:36 +01:00
Ludovic Marie 8177a6f450 Merge pull request #1807 from ludovic111/fix/no-nether-portal-in-the-end
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:03:49 +01:00
Ludovic Marie f6b882c9da Merge pull request #1802 from ludovic111/fix/mob-flags-and-swings
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:01:58 +01:00
Ludovic Marie 9e248a3214 Merge pull request #1797 from ludovic111/fix/tool-use-on-block-ends-click
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:00:04 +01:00
Ludovic Marie 781b7b877b Merge pull request #1796 from ludovic111/fix/potion-contents-over-network
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:58:08 +01:00
Ludovic Marie 50f4c913a5 Merge pull request #1868 from ludovic111/fix/modrinth-loader-profiles
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:55:40 +01:00
Ludovic MarieandClaude Opus 5.5 9ec5f1ddbc Rockets: a guest's boost rocket hurts them; fireworks sent to a server's clients
FireworkRocketEntity.dealExplosionDamage hurts attachedToEntity with the
full 5 + 2 per explosion: a guest's boost rocket now hurts that guest
instead of nobody. The host's encoder (network_stack) now sends
minecraft:fireworks and minecraft:firework_explosion too, so a rocket's
flight duration and a star's explosion reach the server thread's window,
LAN guests and a dedicated server's players; the creative slot encoder
shares it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:51:09 +01:00
Ludovic Marie e555c4ee48 Merge pull request #1831 from kacpherek/fix/1503-guest-chest-comparator
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:43:16 +01:00
Ludovic Marie f5adaec209 Merge pull request #1828 from kacpherek/fix/1335-crafting-table-valid
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:41:29 +01:00
Ludovic Marie 406fbd7793 Merge pull request #1827 from kacpherek/fix/1334-menu-range
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:39:17 +01:00
Ludovic Marie c0567a323c Merge pull request #1804 from kacpherek/pr/surface-rule-bands
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:38:20 +01:00
Ludovic MarieandClaude Opus 5.5 f969415c7a Creative stacks: reuse the wire codec both sides already had
Horizon already encoded stacks for the wire (lan::guest::stack_to_network,
what a Horizon server sends its clients: damage, enchantments, stored
enchantments, repair cost, custom name, dyed colour, trim, maps, charged
projectiles, books) and decoded them on the client (remote.rs's
protocol_components + item_data::decode_components, every component it has
a codec for). The new encode/apply pair covered six components and
duplicated both.

- The client's SetCreativeModeSlot stack goes through stack_to_network.
- The decoder moves into item_sync::from_network, used by the client for
  what its server sends and by the server (Hub::stack_from_network) for a
  creative client's slot, so the server now keeps every component the
  client's decoder reads, not six.
- One round-trip test covers encode then decode with registries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:36:52 +01:00
Ludovic Marie 0713ee5ec0 Merge pull request #1803 from kacpherek/pr/keep-scheduled-ticks
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:36:08 +01:00
Ludovic Marie f9d85d2e38 Merge remote-tracking branch 'origin/main' into cg14/pr1874 2026-10-25 19:28:34 +01:00
Ludovic Marie b4f12543ca Merge remote-tracking branch 'origin/main' into cg14/pr1850
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 19:26:13 +01:00
Ludovic Marie 4604491fcb Merge pull request #1691 from socksprox/fix/translucent-particles 2026-10-25 19:23:59 +01:00
rdbtCVSandOpenAI Codex b3840adccd Fix renderer backend fallback after surface creation failure
Keep surface creation errors in the backend failure list so Windows Auto can try DirectX 12 after Vulkan fails.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 18:08:03 +00:00
Joansitoh 90eda915d5 Add a lore tooltip harness against ItemLore.addToTooltip.
The styled lines have to keep matching vanilla, including an explicit non-italic span and a lore component hidden by tooltip_display.
2026-10-25 18:58:52 +01:00
Ludovic MarieandClaude Opus 5.5 bdbc816486 Creative search: match the localized hover name, not the item's base name
The base name (Tipped Arrow, Potion, White Banner) matched where vanilla's
first tooltip line (Arrow of Poison, Water Bottle, Ominous Banner) doesn't:
the creative harness found extra results for 'e', 'potion', 'tipped' and
'white'. name_line builds the full hover name through display_get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:53:20 +01:00
Ludovic Marie 6f8cad0020 Merge pull request #1692 from socksprox/fix/streamed-server-pack-zip 2026-10-25 18:45:56 +01:00
Jan PresandOpenCode 13508cbd9c Creative: Ctrl+Backspace deletes a word in the search
The creative inventory's search box is an append-only EditBox of 50
characters, but its editing was a plain String pop, so Ctrl+Backspace
removed one character instead of the word before the caret, unlike every
other text field and vanilla's EditBox.keyPressed.

Move EditBox.getWordPosition and deleteWords into one shared helper in
ui.rs (the UI and chat text fields had two copies of it), and route a
Ctrl+Backspace in the creative search through it. A Select All selection
is still what backspace deletes first, as in vanilla.

Fixes #1764.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash Free) <[email protected]>
2026-10-25 17:41:07 +00:00
socksproxandDevin 72b9daabf0 Resolve legacy § codes where the font draws text
Name tags and styled HUD text drew a span's § codes as glyphs instead of
restyling what follows, as StringDecomposer.iterateFormatted does. A team
prefix like "§c" showed literally over players' heads, and a resource
pack's styled menu.disconnect kept its codes on the leave-server button.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 18:24:46 +01:00
Ludovic Marie 1087739079 Merge pull request #1877 from ludovic111/roadmap/decided-2026-10-25
🤖 Agent: merged after verify; Ludovic's yes/no on the 54 candidates from the project's history (roadmap check, 20 roadmap tests, 31 website tests, site builds with 90 items).
2026-10-25 18:17:00 +01:00
Ludovic Marie d8ed068db8 Merge pull request #1781 from levzzz5154/fix/vehicle-passenger-pose
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 18:16:18 +01:00
Ludovic MarieandClaude Sonnet 5.5 2ec2964962 Roadmap: the items Ludovic decided on (55 added, 4 updated, 3 areas)
Adds the ideas he said yes to, as roadmap items with plain and dev text,
and extends ai-companion, new-versions, benchmarks and intel-mac. New
areas: platforms, ai, community.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 18:09:13 +01:00
Ludovic MarieandClaude Opus 5.5 bb414b263c Merge main into fix/vehicle-passenger-pose: keep both lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:08:13 +01:00
Ludovic Marie f324251563 Merge remote-tracking branch 'origin/main' into cg14/pr1813 2026-10-25 17:53:14 +01:00
socksproxandDevin f2832f83ef check: cover the ui open/close-screen outputs
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:53:13 +01:00
Ludovic MarieandClaude Opus 5.5 5ee1130719 horizon-dualsense: iOS comments name only what exists
The repo has no horizon-gamecontroller crate, and on iOS the Link never
opens, so no rumble covers vibration there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:53:13 +01:00
Alexander KrylovandHermes Agent 9fe77c7a56 Creative: a stack keeps its components when it crosses the wire
`Game::to_network` wrote damage alone into SetCreativeModeSlot, and
`item_from_network` read back damage and enchantments, so anything else a
creative stack carried was gone once the server echoed the inventory: a
named sword lost its name, trimmed armour its trim, an enchanted book its
stored enchantments and its repair cost.

`item_sync` is the pair for the components Horizon models: `encode` writes
them with the synced registries' ids (an unknown name is left out rather
than sent as id 0, which is a different entry) and `apply` reads them back
into the same `StackData` fields `item_data::decode_components` fills.

Potions are not carried yet: `minecraft:potion_contents` has no id or codec
in horizon-protocol, so a creative potion still arrives as an uncraftable
one (#1832). Nor are containers, charged projectiles, written books and a
map id, which the packet models but this patch does not write.

Refs #1832

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 19:49:08 +03:00
Ludovic MarieandClaude Opus 5.5 6443847633 Launcher: add the panorama, Unifont, languages and sounds prepared resources lack
Versions prepared while the asset store could not be read (Prism Launcher
installs since 0.2.48) are silent with a grey title screen. Like the built-in
packs (#1539), the launcher now copies each of these from the player's own
asset store into prepared resources that lack it, at start, after an install
and before Play, without preparing again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 b873f30c29 wears_freeze_immune above sync_player_body's doc comment, which it had taken over
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:41:04 +01:00
Ludovic MarieandClaude Opus 5.5 8c54e18587 /ride dismount: the boats' BOARDING_COOLDOWN, not a second 60
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:40:41 +01:00
Ludovic MarieandClaude Opus 5.5 0dfc6d8700 command_exec harness: summoned mobs' saved attribute bases and health (#1483)
A cow summoned with max_health and movement_speed bases, and a zombie
with an attack_damage base and a saved Health, against vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:38:28 +01:00
Ludovic Marie b983282247 Merge remote-tracking branch 'origin/main' into cg14/pr1850
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 17:38:04 +01:00
Ludovic MarieandClaude Opus 5.5 a495fcf7a7 End crystal: one blast call for both paths
The respawn check is read before the blast, so the two copies of the
explosion collapse into one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:37:51 +01:00
Ludovic Marie 83e8cbc4b6 Merge remote-tracking branch 'origin/main' into cg14/pr1856 2026-10-25 17:37:23 +01:00
Ludovic MarieandClaude Opus 5.5 b729cfc833 Furnace experience: createExperience's chance, orbs, every removal, guests
- createExperience floors count * experience and adds one with the chance of
  the fraction (a level random nextFloat); flooring alone paid nothing for a
  single iron ore (0.7).
- The payout pops ExperienceOrb.award's orbs at the player, recipe by recipe,
  instead of adding points directly.
- A server's player (the default single-player path, LAN and dedicated)
  gets it too: lan_machine_click pays when the guest takes from the output.
- AbstractFurnaceBlockEntity.preRemoveSideEffects pays at the block's centre
  on any removal (explosions, /setblock, creative breaking), after the
  contents spill, so it moves from break_block to the removal side effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:37:22 +01:00
Ludovic MarieandClaude Opus 5.5 0298d10988 Shulker boxes: one canFitInsideContainerItems for both menus, and a unit test
The client's menu and the server's guest menus now share
menu::fits_inside_container_items, and a resource-backed test checks
that a guest's shulker box menu refuses shulker boxes by pickup,
shift-click and number key while other containers take them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:31:37 +01:00
Ludovic Marie 8942d00ace Merge remote-tracking branch 'origin/main' into cg14/pr1857 2026-10-25 17:30:38 +01:00
Ludovic MarieandClaude Opus 5.5 0609fb2beb Tooltip: lore after the enchantments, as ItemStack.addDetailsToTooltip
Vanilla adds LORE after CONTAINER, BANNER_PATTERNS, FIREWORKS,
POTION_CONTENTS, TRIM and the enchantments, and before the attribute
modifiers, not right under the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:30:37 +01:00
Ludovic Marie e37770bbfe Merge remote-tracking branch 'origin/main' into cg14/pr1858 2026-10-25 17:30:01 +01:00
Ludovic MarieandClaude Opus 5.5 70aff6e90b Death screen: empty message with showDeathMessages off; forget a guest's record when they leave
- ServerPlayer.die sends CommonComponents.EMPTY to the death screen when
  showDeathMessages is off, as the local player's death_message already
  does; the combat record is only read when the rule is on.
- A guest's CombatTracker leaves guest_combat with them, so a long-running
  server doesn't keep one per connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:30:01 +01:00
Ludovic Marie b9d2d93ccb Merge remote-tracking branch 'origin/main' into cg14/pr1806 2026-10-25 17:27:41 +01:00
Ludovic MarieandClaude Opus 5.5 df23e1bfed Keep vanilla's death screen message; resolve with the loaded language
- Leave ServerPlayer.die's message as it was: with showDeathMessages off
  vanilla sends CommonComponents.EMPTY to the death screen, and #1858
  reworks the death message from the combat record.
- The effects panel and recipe book tooltips use Names::display_get, so a
  key missing from the language still falls back instead of showing the key.
- net::load_language reuses the language lang already loaded (packs
  included) instead of reading it from disk again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:41 +01:00
Ludovic MarieandClaude Opus 5.5 75951a769a Merge #1847 into #1849: LocatorBarClient's respawn and use commands, the harness scaffold
The three harnesses add commands to the same LocatorBarClient lines;
this branch now builds on #1847 (itself on #1843), keeps all three
commands, and test_cursor_death_parity.py runs through
locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:31 +01:00
Ludovic MarieandClaude Opus 5.5 94bc569a7f Merge #1843 into #1847: share LocatorBarClient's respawn command and the harness scaffold
Both add the same respawn command to LocatorBarClient; this branch now
builds on #1843's, and test_pearl_death_parity.py runs through
locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:00 +01:00
Ludovic MarieandClaude Opus 5.5 50727eb5c3 Harnesses: one scaffold for one-player dedicated server scripts
test_hardcore_respawn_parity.py copied test_locator_bar_parity.py's
build and run code; both now share locator.sides, and one-player
scripts run through locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:26:43 +01:00
Ludovic MarieandClaude Opus 5.5 65d3edfc3a Launcher: find the client jar of a Modrinth App mod loader profile (#1783)
The Modrinth App keeps a Fabric or Quilt profile's vanilla client jar as
`versions/<v>-<loader>/<v>-<loader>.jar`; a player who only played such a
profile has no `versions/<v>/`, and Prepare said Minecraft was not found.
Those jars are now candidates too, checked against the pinned sha1 like the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:26:03 +01:00
Ludovic MarieandClaude Opus 5.5 ee74553314 Merge #1827 (container reach) into #1828: share its reach helper
Both PRs add a stillValid check to the same tick functions; this branch
now builds on #1827's lan::guest::within_block_range for the guest's and
the in-frame table checks, and the in-frame check sits beside the
container one in storage.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:53 +01:00
Ludovic MarieandClaude Opus 5.5 6d3cd330d1 One isWithinBlockInteractionRange for guests, the in-frame player, stations, lecterns and signs
The reach test was written out four times (stations, lecterns, the
in-frame container check and the guest's); they now share
lan::guest::within_block_range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:10 +01:00
Ludovic MarieandClaude Opus 5.5 204859a2cb Launcher: a preparation reusing imported game files still finds the assets
When an update's preparation reused the game files an earlier version imported,
`preparer` handed the chosen folder as it was to the sound copy and the panorama,
fonts, packs and languages step. A Prism Launcher instance or Modrinth App profile
holds no asset store, so the game was silent with a grey title screen, and
Prepare again did the same. It now resolves the folder (linking another
launcher's files into the official layout) either way; with reused imports a
missing one is a log line, not an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:20:41 +01:00
socksproxandDevin af7a685b5c Mod API: capability ui — mod-owned screens and settings pages
New WIT interface `ui` (text, item and list widgets; open-screen,
close-screen, set-settings) plus a `ui-events` export (select, closed,
settings-opened), in a second world `horizon-ui-mod` so mods built before
it keep loading. The game lays out and draws the widgets with its own
menus' code: a screen the mod owns (Esc/Done to leave) and a Settings
button on the mod's Options > Mods row that opens its page.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:09:25 +01:00
kaicsm fdfa3d568d Merge branch 'ludovic111:main' into fix/client-mods-server-thread 2026-10-25 13:01:39 -03:00
Joansitoh d9360338f6 Show item lore in tooltips with vanilla purple italic styles.
Stack tooltips skipped minecraft:lore entirely; draw the component lines as Text so colours and explicit non-italic spans survive.
2026-10-25 16:33:35 +01:00
JasonGuandOpenCode DeepSeek V4.1 Flash feec1b67e8 Server deaths: build the message from the player's own combat record
A guest's death only had `death_cause`, which `/kill` alone sets, so a
server-thread, LAN or dedicated death fell back to "X died" or a generic
cause. Give each guest its own CombatTracker, fed by the motion events
the server already records, and take the message from
CombatTracker.getDeathMessage with the dying player's own name. The
wither's skull names the wither that launched it (projectile owner).

The death screen now resolves the server's component in the client's
current language instead of the session's en_us table, so it matches the
chat line.

Co-Authored-By: OpenCode DeepSeek V4.1 Flash <[email protected]>
2026-10-25 23:29:23 +08:00
Alexander KrylovandHermes Agent bf81ae6358 Furnace: pay the experience a smelt earned
RecipesUsed was written as an empty compound and never read, so a furnace
paid nothing when its output was taken and nothing when it was broken.

- Furnace keeps the per-recipe counter, saves and loads it, and floors each
  recipe on its own as AbstractFurnaceBlockEntity.createExperience does.
- FurnaceResultSlot.checkTakeAchievements pays on a take: the counter is
  read and cleared when the output slot loses an item.
- AbstractFurnaceBlockEntity.playerDestroy pays the same counter as orbs at
  the block when it is broken, whatever the tool.

Fixes #1732

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 18:28:57 +03:00
Kacpherek 5bfc649edd Drop the cursor stack of a dead player
A stack held on the inventory cursor at death stayed there through the
respawn, even with keep_inventory off. In vanilla the dead player is
removed 20 ticks after dying (or when it respawns sooner), and
Player.remove's inventoryMenu.removed drops what the cursor held where
the player died. Do the same for server players and the in-frame
player, and add a harness that dies with a cursor stack on a dedicated
server and compares with vanilla.
2026-10-25 16:09:09 +01:00
Kacpherek 0c017678eb Keep shulker boxes out of shulker boxes on the server
ShulkerBoxSlot.mayPlace refuses items that cannot fit inside container items, which BlockItem says of every shulker box. The server's menus for guests treated a shulker box's slots like a chest's, so with the server thread a shulker box could be clicked, shift-clicked or swapped into another. Apply the slot rule when the open menu is a shulker box.
2026-10-25 16:05:57 +01:00
Kacpherek 10efca9e6c Discard a dead player's ender pearl when the rule says so
With ender_pearls_vanish_on_death on, a pearl thrown just before death
kept flying and pulled the respawned player back to it. Vanilla's
ThrownEnderpearl.tick discards the pearl instead of ticking it while
its owner is dead. Add a harness that throws a pearl, dies and respawns
on a dedicated server and compares the teleports with vanilla's.
2026-10-25 16:05:52 +01:00
Kacpherek 436c2fefb4 Freeze server-thread players in powder snow
With the server thread, the host's copy of a player never set isInPowderSnow from the blocks along its moves and never ran LivingEntity.aiStep's freezing, so a frozen player took no freezing damage and the client's own damage was overwritten. Record powder snow's FREEZE on the server path and run the freezing countdown and its damage every 40 ticks there. The guest's freeze-immune armor now comes from its worn armor, as for the local player.
2026-10-25 16:01:39 +01:00
Kacpherek f48cfb7966 Let /ride mount and dismount mob riders
/ride now looks up a mob's vehicle as well as the player's, so mobs
can mount mobs, get the already-riding and loop errors, and dismount
to the vehicle's dismount spot. execute on vehicle uses the same
lookup. The command_exec corpus gets a mob riding group.
2026-10-25 16:01:39 +01:00
Kacpherek 0481dbdb81 Make hardcore players spectators when they respawn
A hardcore death offered another survival life. Vanilla's death screen
says Spectate in hardcore, and the server makes the respawned player a
spectator (ServerGamePacketListenerImpl.handleClientCommand). Do the
same on the server and in the in-frame world, and add a harness that
compares a hardcore respawn on a dedicated server with vanilla's.
2026-10-25 16:01:39 +01:00
Ludovic MarieandClaude Opus 5.5 c5a640dd56 Endermen, endermites, blazes, eyes of ender, dried ghasts and end portals make their ambient effects
The client-side branches vanilla runs on its own level: Enderman and
Endermite aiStep (two portal particles a tick), Blaze aiStep (two large
smoke particles, the burn sound one tick in 24), EyeOfEnder.tick (a
portal particle behind it, four bubbles in water), and the
DriedGhastBlock and EndPortalBlock animateTick. They run on the window's
game in-frame and as a server's client, never on a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:59:56 +01:00
rdbtCVSandOpenAI Codex 8ffd595575 fix: suppress secondary explosions from destroyed End crystals
Pass the crystal damage cause through destruction so explosion damage removes crystals without another blast, including during dragon respawn. Keep fight callbacks and direct-hit blasts.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 14:58:42 +00:00
Kacpherek 13e83e4e63 Read saved attribute bases for every mob
LivingEntity.readAdditionalSaveData applies the attributes list for any
living entity, then sets the health (the max health when none is saved).
Only horses read max_health and movement_speed, so a summoned cow kept its
type defaults. Read max_health, movement_speed and attack_damage for all
mobs before the health; horses still read jump_strength.
2026-10-25 15:51:29 +01:00
Kacpherek 3843a9a4aa Use the team-formatted player name in chat
Chat, /say, /me and /tell now name the sender the way
Player.getDisplayName does: the team prefix, the name and the suffix
in the team colour. On a server the hub gets the scoreboard's teams
each tick; in-frame the existing player_display_name is used.
2026-10-25 15:51:11 +01:00
Kacpherek 467277ef6d Update comparators when a guest changes a chest through its menu
A container click from a server-thread or LAN player wrote the slots but
never called BlockEntity.setChanged, so comparators kept the old output.
Notify the block entity when the click changed the container's slots.
2026-10-25 15:48:51 +01:00
vlesmentieandClaude Opus 5.5 4bfa496173 Lava cauldrons burn the player inside them
The player's inside-block sweep skipped lava cauldrons, so standing in
one did nothing. It now tests the player against LavaCauldronBlock's
entity-inside shape (the cauldron's shape or its lava, Block.column(12,
4, 15)) the way Entity.checkInsideBlocks does, and applies
LavaCauldronBlock.entityInside: CLEAR_FREEZE, LAVA_IGNITE (15 seconds)
and Entity.lavaHurt (lava, 4), for the game's own player and a server's.

Fixes #1320.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:47:21 -03:00
Kacpherek 18ec4145c4 Close crafting tables that are removed or out of reach
CraftingMenu.stillValid needs the block to still be a crafting table and the
player within reach of it. The table's position is now kept with the open
grid, so the menu closes in the tick (giving its grid back) and a stale
click no longer crafts, in single player and for LAN guests.
2026-10-25 15:44:49 +01:00
Kacpherek ed7d88b213 Close container menus when the player moves out of reach
Container.stillValidBlockEntity also checks the player's block interaction
range plus a 4-block buffer. Guest menus now close in the tick once out of
reach and ignore clicks while invalid, and the in-frame single player menu
closes the same way.
2026-10-25 15:40:19 +01:00
vlesmentieandClaude Opus 5.5 5fb0cad6e7 Wither roses give the player Wither
WitherRoseBlock.entityInside adds getBeeInteractionEffect (Wither, 40
ticks) to a living entity inside it on a server level that isn't
peaceful, unless it is invulnerable to wither damage. The player's
inside-block dispatch had no wither rose case, so neither singleplayer
mode applied it. The sweep now asks for it, the game adds it to its own
player, and the server-thread path adds it to a server's player.

Fixes #1319.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:31:02 -03:00
Fo 4b5fa2ea6b build: make horizon-dualsense compile on iOS
hidapi 2.6.7 has no iOS backend, so the crate failed to build for
aarch64-apple-ios (hidapi's build script looked for hid.h). Gate the
hidapi and cpal dependencies plus all HID device/haptics code behind
cfg(not(target_os = "ios")) and provide an inert stub on iOS
(Link::start() is a no-op), mirroring how horizon-gamecontroller
already compiles empty on iOS.

Verified with cargo check -p horizon-dualsense --target
aarch64-apple-ios: clean, no warnings. Host clippy/tests are left to
CI (this sandbox has no C toolchain for the host target).

Co-Authored-By: Fo <[email protected]>
2026-10-25 14:28:55 +00:00
Ludovic MarieandClaude Opus 5.5 a9e6818d2a Merge main (workspace: main's registry exclude + this branch's predator-missile example)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:09:03 +01:00
vlesmentieandClaude Opus 5.5 e9d2c10417 Peaceful zombie horses spawn without their zombie rider
ZombieHorse.finalizeSpawn gets its rider from EntityType.create, which
returns null for a notInPeaceful type (the zombie) in peaceful, so vanilla
spawns the zombie horse alone there. spawn_jockeys created the rider
regardless. mobs/finalize.rs already skips it through level.create.

Fixes #1761.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:06:00 -03:00
Ludovic MarieandClaude Opus 5.5 a2fccf0205 Flint and steel lights nether portals only in the Overworld and the Nether
BaseFireBlock.inPortalDimension: a frame lit in the End stays a fire.
The world code's fire placement already checked it; the flint-and-steel
path (Game::try_light_portal) didn't, so a portal could be lit and used
in the End in every mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:52:17 +01:00
JasonGuandOpenCode DeepSeek V4.1 Flash b457f6382a Localization: names, container titles, death text and search follow the language
- item and block names in tooltips, held labels, command feedback and the
  creative tabs read the selected language; the pinned English map stays
  for logic and search
- Game::translate resolves through lang::get_or, so station titles follow
  the language screen
- container titles and Inventory labels for chests, hoppers, furnaces,
  smokers, blast furnaces, crafters, shulkers and mounts are translated
- server-resolved components (menu titles, death messages, command
  suggestion tooltips) load the selected language over en_us, and follow
  a language change while a world is open
- death messages always reach the death screen translated; the chat
  broadcast still obeys showDeathMessages
- creative search matches the localized display name
- sign editors use sign.edit and hanging_sign.edit

Co-Authored-By: OpenCode DeepSeek V4.1 Flash <[email protected]>
2026-10-25 21:42:01 +08:00
Kacpherek dbafda06ed Surface: specialise the material rule per gradient band and surface side
Each chunk's compiled rule is specialised once for every band of y that
its vertical gradients bound, and for blocks below and at or above the
preliminary surface when the rule asks for it. Conditions those facts
decide are folded away, so a deep stone block evaluates only the rules
that can apply there (ore veins, the underground rules) instead of
walking the whole surface tree. Conditions only read the position and
the world, so the blocks are the same.

Surface stage, one thread, seed 1234: 2.45 -> 2.08 ms per chunk.
2026-10-25 14:40:37 +01:00
Kacpherek e181b17e17 Register new chunks' ticks before post-processing them
postprocess_chunks ran before tick_blocks registered the chunks that
arrived since the last tick, so the fluid and block ticks their marks
schedule went to chunks the scheduler did not know and were dropped.
Vanilla registers a chunk's tick containers before postProcessGeneration.
2026-10-25 14:40:37 +01:00
Kacpherek f56785c2f5 Keep a chunk's scheduled ticks when it unloads before the next tick
attach_save_data took a chunk's ticks only from the tick scheduler, which
registers new chunks once a tick. A chunk loaded and unloaded again in
between was saved with its block and fluid ticks cleared, though its own
saved list still held them.
2026-10-25 14:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 68deac8533 Server clients see mobs' aggression and arm swings
Mob.DATA_MOB_FLAGS_ID now carries the aggressive bit (with its default
0, so it goes back off) and every client mob reads no AI, left-handed
and aggressive from it: a vindicator draws its axe, skeletons their bow,
piglins and illagers their attack poses on the server thread, LAN guests
and dedicated servers. Mobs' swings (LivingEntity.swing) go out as
ClientboundSwingAnimationPacket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:38:44 +01:00
Ludovic MarieandClaude Opus 5.5 2f1f0150eb mobs diagnostics: say why passenger is set outside the json! macro
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 806f7d87f3 Merge origin/main into fix/vehicle-passenger-pose
Keeps main's crystals check and this branch's boat-dismount-pose check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:29 +01:00
socksproxandDevin 41b799042a Sync mobs' aggressive and item-use flags so clients pose them right
Mob.DATA_MOB_FLAGS_ID's aggressive bit and LivingEntity's
DATA_LIVING_ENTITY_FLAGS never reached a client for mobs: on the
server thread and for LAN guests a skeleton kept its arms down while
its arrows flew, and a drawn bow never pulled. Send both entries (with
their defaults so a flag going back to 0 is sent too), decode them for
every mob, and let a remote mob's item-use ticks count up as
LivingEntity.updatingUsingItem does. The same entries drive the zombie
arms, the vindicator's axe, the drowned's trident and the spellcasters'
poses, so the pillager's charging and evoker's/illusioner's casting
entries go with them. A left-handed mob now mirrors the pose and the
held item (Mob.getMainArm), and a bow in the off hand counts for
LivingEntity.isHolding.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:26:55 +01:00
Ludovic MarieandClaude Opus 5.5 df626cf2f3 A tool that changes a block ends the click on a server's client
On a server's client (single player on the server thread, LAN guests), a
shovel making a path, an axe stripping, a hoe tilling or honeycomb waxing
did not end the right click: the client went on to the other hand, so an
off-hand torch was predicted as placed and its hand swung instead of the
tool's (#1289).

Vanilla's client runs `Item.useOn` itself: its SUCCESS swings that hand
and `Minecraft.startUseItem` returns before the off hand. The client now
does the same when its prediction of the tool's use succeeds.

Fixes #1289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:15:28 +01:00
Ludovic MarieandClaude Opus 5.5 40c77c02f0 PalettedContainer: look up a word's filled bits, not divide for them
`locate` found an entry's word by multiplication (the MAGIC table) but
still divided `64 / bits` at run time for the shift, on every block read
and write of a section stored in 5, 6 or 7 bits. A division by a value the
compiler does not know costs tens of cycles on x86: `perf annotate` of a
10-bot server on the ThinkPad put 12% of `PalettedContainer::get`, the
server thread's top function, on that `div`. The table now also holds the
bits a word's entries fill, so the shift is `i * bits - word * filled`.
A test checks `locate` against the division for every width and entry.

ThinkPad (i5-7200U), 4M random reads, 5-7 bit palettes: 28.5-36.9 ->
24.6-32.4 ns over 2,000 sections, 6.7-7.1 -> 5.4-5.8 ns within one;
4 and 8 bits (shifts, unchanged) the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:50:35 +01:00
Ludovic MarieandClaude Opus 5.5 9e21868dfa Potions keep their potion across the connection
A server's client (single player on the server thread, LAN guests, a
dedicated server's players) saw every potion as an "Uncraftable Potion"
in the water colour: the server never wrote `minecraft:potion_contents`
and the client read past it. A potion taken from the creative menu also
reached the server without its potion, so it splashed as water.

PotionContents now goes as vanilla's stream codec writes it: the potion
by its `minecraft:potion` id and the custom colour, with the custom
effects and name kept as their bytes. The server writes it for every
stack it sends, the client reads it into the item's components, and a
creative pick and the server's read of it keep the potion too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:46:45 +01:00
Ludovic MarieandClaude Opus 5.5 be378bb773 Particles: vault connections are translucent, shadows keep their range
- FlyTowardsPositionParticle puts vault_connection on the translucent
  layer: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
- The shadow draw started at world_quads - shadow_quads; with the new
  bucket after it, that drew translucent particles with the shadow
  pipeline and dropped as many shadows.
- One atlas lookup per named-sprite particle and frame: the engine keeps
  each sprite's layer beside its rect instead of hashing the name twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:27:49 +01:00
socksproxandDevin 4032e162ce Draw translucent particles after translucent terrain
Quad particles all went into the entity batch, drawn before translucent
terrain with depth writes on: a stained-glass crumb wrote its depth and
the sorted translucent pass then culled the glass behind it, so breaking
glass in front of glass cut holes through the wall.

Vanilla 26.3 submits quad particles to two phases: opaque layers draw
with the solids and translucent layers draw in an "afterTerrain" phase
right after the translucent section pass (the translucent particle
pipeline still writes depth, so ordering is what matters). Mirror that:
HudAtlas records which sprites have a 0 < alpha < 255 texel
(`SpriteContents.transparency().hasTranslucent`), particles classify
their layer the way `SingleQuadParticle.getLayer` does — the type's
hardcoded layer, or `Layer.bySprite` for terrain, item and block marker
sprites — and the entity batch routes those quads to a sixth bucket the
renderer draws immediately after translucent terrain.

Fixes #1679.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:27:49 +01:00
levzzzandCodex 1ec6d6d734 Detach entity passengers when their vehicle is removed
Clear remote passenger mount and pose flags when a vehicle disappears, matching Entity.setRemoved. Add a two-passenger boat destruction check for both single-player paths and LAN observers.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 16:15:36 +05:00
Ludovic MarieandClaude Opus 5.5 1437537cbf Worker threads: leave one core, as vanilla does, not two
Graphics::worker_threads sizes the worker pools (the server thread's, which
generates and lights, and the window's, which meshes). Its automatic count
left two cores to the render and server threads; vanilla's executor leaves
one (Util.maxAllowedExecutorThreads). The workers run behind those threads
(niced on Linux; above four cores, raised server and render threads on
macOS and Windows), so the second core mostly idled: on a 4-thread laptop
only 2 cores generated while a world loaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:57 +01:00
Ludovic MarieandClaude Opus 5.5 c879c22a6a Merge the branch's own main merge
item.rs: the same components either way; keep the branch's order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:45:44 +01:00
Ludovic MarieandClaude Opus 5.5 39904c8051 docs/mods.md: the contract lists the command interface
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 e7ca664e01 Server: a guest's boost rocket is theirs
The server attached every boosting rocket to its own player: it followed
the host, and DATA_ATTACHED_TO_TARGET named the host (or nothing on the
server thread, which has none), so the guest's client never saw a rocket
attached to them and did not boost. The rocket now keeps the guest it was
fired by, follows them and names their entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:32:23 +01:00
Ludovic MarieandClaude Opus 5.5 11035336a0 Server: a gliding player's rocket boosts them
FireworkRocketItem.use attaches a rocket only to a player who is fall
flying. The server tracks a guest's glide (START_FALL_FLYING) but the
body it swaps in for their item use left fall_flying unset, so on the
server thread, LAN and dedicated servers the rocket did nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:24:29 +01:00
socksproxandDevin c99de82044 Merge main into fix/elytra-fireworks: keep firework components beside trim/raw passthrough
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 11:13:42 +01:00
Ludovic MarieandClaude Opus 5.5 5262effa7e cargo horizon-mod check: list the commands a mod runs
Main's check reports every output; the command capability adds one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:48:35 +01:00
Ludovic MarieandClaude Opus 5.5 c2c8df4542 Mods: run a mod's commands on the next tick
Run while the mod's output was shown, a command's chat line reached the
mods again inside that call: a mod that answers a line with a command
that prints one recursed without end, and when a control-socket command
set the hook off, the mod's commands ran with the console's level. Queue
them for the next tick instead, where the player's own level applies (the
server already checks it on a server).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:47:54 +01:00
Ludovic MarieandClaude Opus 5.5 f08546fd74 Merge main into the mod command capability
Cargo.toml: keep main's horizon-shaders member beside predator-missile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:46:18 +01:00
Ludovic MarieandClaude Opus 5.5 e7f85e5bfd Zip: test the streamed shapes Java 25's ZipFile opens
ZipFile refuses a 0xFFFFFFFF size without its Zip64 extra field ("invalid
zip64 extra len size"), so the test builds both shapes it accepts: real
sizes, and the sentinel with the extra (the zip crate reads the real size
from it). With every accepted shape carrying a real size, the inflate
limit needs no special case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:44:31 +01:00
Ludovic MarieandClaude Opus 5.5 272e230371 Merge main into the elytra fireworks branch
Main now reads past components it doesn't model (Component::Raw) and
added ArmorTrim; this branch's Fireworks and FireworkExplosion keep
their structured decode beside them, ahead of the Raw fallback.
remote_projectiles takes main's FallStep import too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:24:06 +01:00
Ludovic MarieandClaude Opus 5.5 90ae953747 Rockets: collapse the boost's nested if (clippy::collapsible_if)
Verify failed on it with warnings denied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:21:48 +01:00
Kaic Salomão da150409ea Complete and color mod commands in the chat on the server thread 2026-10-25 02:49:22 -03:00
Devin 348675c538 Packs: open streamed archives the way Java's ZipFile does
A resource pack written while it streams (Nexo's server behind
resourcepack.gommehd.io, gommehd.net's required pack) leaves every local
header a stub, every uncompressed size at 0xFFFFFFFF and the END record's
entry counts and disk number at 0 and 0xFFFF. Java tolerates all three;
the zip crate's ZipArchive refuses the END record as multi-disk and empty,
so a required server pack failed its reload and the join never got through
(each retry also rebuilt the renderer, once a second).

Count the real central-directory entries and normalize the END record's
disk and count fields when they disagree with it, and bound the inflate
output when an entry's size is the 0xFFFFFFFF sentinel.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 02:52:19 +02:00
socksproxandDevin 372f918e44 Multiplayer: keep firework flight duration, boost gliding players
Two halves of FireworkRocketEntity on a server were missing:

- SetCreativeModeSlot encoded only damage, so a creative-picked rocket
  lost its minecraft:fireworks component (flight_duration and explosions
  gone); the wire codec also had no decoder for it or firework_explosion,
  so a stack carrying them failed to decode at all. The components now
  follow Fireworks.STREAM_CODEC/FireworkExplosion.STREAM_CODEC both ways
  (component ids 71/70).
- A remote rocket never read DATA_ATTACHED_TO_TARGET,
  DATA_ID_FIREWORKS_ITEM or DATA_SHOT_AT_ANGLE, so the server-attached
  boost rocket was an inert marker: attached_to now comes from
  EntityDataSerializers.OPTIONAL_UNSIGNED_INT (the id plus one), the item
  feeds the explosion list, and FireworkRocketEntity.tick's
  isFallFlying boost applies to the local player.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 01:18:53 +02:00
Simon Dräger d4b2e24535 Add mod command capability and Predator Missile example 2026-10-24 13:21:23 -07:00
159 changed files with 9556 additions and 993 deletions

No files matched your search

+32 -2
View File
@@ -3,7 +3,8 @@ name: checks
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
on:
@@ -72,8 +73,37 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
parity:
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- id: scope
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
run: |
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No harness, oracle, probe or runner change: parity-main checks this on main"
fi
parity:
needs: parity-scope
if: needs.parity-scope.outputs.run == 'true'
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
+70
View File
@@ -0,0 +1,70 @@
name: parity-main
# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# The comment on the merge that broke a harness.
pull-requests: write
# tools/parity_main.py base reads earlier runs.
actions: read
concurrency:
group: parity-main
cancel-in-progress: false
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity-main:
runs-on: [self-hosted, horizon-game]
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
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- 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
- name: Main's last green parity-main
id: base
env:
GH_TOKEN: ${{ github.token }}
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
- name: The harnesses the merges since then can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: The merge that broke them
if: failure()
env:
GH_TOKEN: ${{ github.token }}
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: ignore
retention-days: 7
+44 -1
View File
@@ -37,13 +37,56 @@ jobs:
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
- 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"
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
run: |
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
# branch made before a better selection landed gets it too; the head's own runner then runs those
# harnesses. A branch that changes the selection itself selects with its own code.
set -euo pipefail
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
fork_point=$(git merge-base "$BASE_SHA" HEAD)
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
own
exit
fi
had_reach=$(git ls-files tools/parity_reach.py)
git show origin/main:tools/parity.py > tools/parity.py
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
status=0
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
git checkout -- tools/parity.py
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
cat .local/parity-selection.txt
if [ "$status" != 0 ]; then
echo "main's selection failed: the head's own selection runs"
own
exit
fi
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
# The summary line says how many would run: a count that doesn't match the names read (a format change)
# must not let the job pass untested.
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
count=$(printf '%s' "$names" | grep -c . || true)
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
own
exit
fi
if [ "$count" = 0 ]; then
echo "Nothing to run."
exit 0
fi
# shellcheck disable=SC2086
python3 tools/parity.py $names --timeout 2400
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+6 -4
View File
@@ -1,6 +1,7 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
@@ -18,7 +19,8 @@ name: release
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
workflows: [checks]
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
workflows: [checks, parity-main]
types: [completed]
branches: [main]
schedule:
@@ -64,8 +66,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
# main's newest commit with green checks.
# The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
# otherwise main's newest commit with green checks and parity-main.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
+1 -1
View File
@@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
Generated
+97
View File
@@ -1116,12 +1116,44 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
dependencies = [
"khronos_api",
"log",
"xml-rs",
]
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glow"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [
"js-sys",
"slotmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
dependencies = [
"gl_generator",
]
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -1352,6 +1384,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "horizon-mods-test-ui-guest"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1805,6 +1844,23 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"pkg-config",
]
[[package]]
name = "khronos_api"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -2677,6 +2733,13 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -3142,6 +3205,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -4267,6 +4339,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
"wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
@@ -4282,6 +4355,15 @@ dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "30.0.1"
@@ -4304,13 +4386,18 @@ dependencies = [
"bytemuck",
"cfg-if",
"cfg_aliases",
"glow",
"glutin_wgl_sys",
"gpu-allocator",
"hashbrown 0.17.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"naga",
"naga-types",
"ndk-sys",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4330,10 +4417,14 @@ dependencies = [
"smallvec",
"static_assertions",
"thiserror 2.0.21",
"wasm-bindgen",
"wayland-sys",
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-result 0.4.1",
]
[[package]]
@@ -5074,6 +5165,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml-rs"
version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
+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/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", "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", "xtask"]
resolver = "2"
[workspace.package]
+638 -13
View File
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
@@ -80,6 +80,16 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
</details>
#### Pre-generate an area with /pregen
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
### Mods and API
#### Horizon's mod registry
@@ -94,6 +104,40 @@ More: [docs/mods.md](docs/mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
</details>
#### Tools for people who make mods
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
</details>
### Gameplay parity
#### Single player as a server: the last differences
@@ -130,6 +174,16 @@ More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](http
</details>
#### Horizon's own shader format and kit
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
### Horizon extras
#### Discord Rich Presence
@@ -160,11 +214,65 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
#### Fresh benchmarks against vanilla
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster world creation and entry on small CPUs
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
#### Servers that hold 20 TPS with 50 to 100 players
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
<details><summary>Technical</summary>
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
</details>
#### Low-end computers: Steam Deck, old Macs, 8 GB
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
<details><summary>Technical</summary>
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
</details>
#### Native upscaling
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
</details>
#### A batch of targeted optimisations
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
</details>
@@ -180,6 +288,136 @@ The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-
</details>
#### A Convert to Rust button for what isn't rewritten yet
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### Mods with code: machines, magic, mobs
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Data packs in the mod browser
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
<details><summary>Technical</summary>
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
### Servers and multiplayer
#### Prepare a version without Java
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
#### Server console, bans and a full admin API
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Hosting polish: Windows service, safe updates
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
#### Guided hosting with Tailscale and an invite button
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
<details><summary>Technical</summary>
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
More: [docs/hosting.md](docs/hosting.md)
</details>
#### An official Docker image for servers
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
<details><summary>Technical</summary>
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
More: [docs/server.md](docs/server.md)
</details>
#### A server you install with one command
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
</details>
### Mods and API
#### Mods that read and change the world
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
<details><summary>Technical</summary>
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
More: [docs/modpacks.md](docs/modpacks.md)
</details>
#### Machines and blocks with inventories (API 0.4)
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
#### Lua and other languages for mods
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
<details><summary>Technical</summary>
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
</details>
### Gameplay parity
#### Pistons push players and mobs
@@ -192,6 +430,50 @@ Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (hon
</details>
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
#### Villages and combat, exactly as in vanilla
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
#### Choose the audio output device
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
### Rendering
#### Well-known shader looks, rewritten for Horizon
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
<details><summary>Technical</summary>
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
More: [docs/shaders.md](docs/shaders.md)
</details>
### Launcher
#### Microsoft sign-in (coming soon)
@@ -206,6 +488,18 @@ More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
<details><summary>Technical</summary>
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
</details>
### Platforms
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
@@ -216,29 +510,359 @@ x86_64 game and launcher builds, picked per processor ([#1171](https://github.co
</details>
## Later
#### Wayland and Hyprland polish
### Gameplay parity
#### Following new Minecraft versions
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
<details><summary>Technical</summary>
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
### AI and agents
#### A versioned agent-control protocol
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
<details><summary>Technical</summary>
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
More: [docs/api/stability.md](docs/api/stability.md)
</details>
#### Port mods with your own AI agent
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
</details>
### Community
#### Horizon on X (coming soon)
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
<details><summary>Technical</summary>
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
</details>
### Code and tools
#### Pipeline protection and a GitHub App for agents
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
#### Signed updates everywhere, installers included
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
## Later
### Performance
#### Exact far terrain from real chunks
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
</details>
### Modpacks
#### Older Fabric and Forge mods
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
<details><summary>Technical</summary>
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### A modpack's own resource and shader packs
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
<details><summary>Technical</summary>
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Whole modpacks, such as ATM10, rewritten
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
<details><summary>Technical</summary>
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
</details>
#### Running Java mods that aren't rewritten
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
### Servers and multiplayer
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Online-mode checks for Play with Friends
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
<details><summary>Technical</summary>
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
</details>
### Mods and API
#### Orbital Laser on the normal game
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
<details><summary>Technical</summary>
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
</details>
#### Native versions of popular client mods
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
<details><summary>Technical</summary>
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
More: [docs/mods.md](docs/mods.md)
</details>
#### Create-style physics as a native mod
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
#### Proximity voice chat as a native mod
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
<details><summary>Technical</summary>
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
</details>
### Gameplay parity
#### Every Minecraft version, prepared automatically
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
<details><summary>Technical</summary>
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
</details>
#### Controllers: tested on real pads, and in the launcher
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
<details><summary>Technical</summary>
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
</details>
### Rendering
#### A light default shader pack
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
<details><summary>Technical</summary>
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
More: [docs/shaders.md](docs/shaders.md)
</details>
#### Optional ray tracing
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
<details><summary>Technical</summary>
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
</details>
### Launcher
#### Lean installs that use less disk
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
<details><summary>Technical</summary>
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
</details>
### Platforms
#### Horizon on Linux ARM64
Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
#### Android, iOS and the browser
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
#### AppImage and a Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
</details>
### Horizon extras
#### An optional AI teammate
#### A complete video studio inside the game
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
</details>
#### Game menus in the launcher's style
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
</details>
### AI and agents
#### An optional AI teammate
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
### Community
#### A video series about Horizon
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
<details><summary>Technical</summary>
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
</details>
@@ -330,6 +954,7 @@ Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
+3
View File
@@ -224,7 +224,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
Output::Feedback { text, error } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
});
}
match failed(&mods) {
+5
View File
@@ -68,6 +68,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
@@ -0,0 +1,53 @@
//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
use horizon_client::{
item_data::parse_stack,
item_tooltip::{TooltipDisplay, lore_texts},
};
use std::{error::Error, fmt::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let mut output = String::new();
for line in input
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let stack = parse_stack(line)?.ok_or("empty stack")?;
let display = TooltipDisplay::for_stack(&stack);
if !display.shows("minecraft:lore") {
writeln!(output, "-")?;
continue;
}
let lines = lore_texts(&stack);
if lines.is_empty() {
writeln!(output, "-")?;
continue;
}
let mut rendered = Vec::new();
for text in lines {
let mut spans = Vec::new();
for span in &text.spans {
if span.text.is_empty() {
continue;
}
let style = &span.style;
spans.push(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
span.text,
style.color.map(|c| c as i32).unwrap_or(-1),
style.bold == Some(true),
style.italic == Some(true),
style.underlined == Some(true),
style.strikethrough == Some(true),
style.obfuscated == Some(true),
));
}
rendered.push(spans.join(" | "));
}
writeln!(output, "{}", rendered.join(" || "))?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -49,6 +49,7 @@ mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod light_parity;
mod lore_tooltip_fixture;
mod marker_particles_probe;
mod menu_fixture;
mod mipmap_probe;
@@ -133,6 +134,7 @@ probes! {
interaction_input_fixture,
lan_probe,
light_parity,
lore_tooltip_fixture,
marker_particles_probe,
menu_fixture,
mipmap_probe,
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
}
}
}
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
cooking_timer: menu.data[2],
cooking_total: menu.data[3],
speed: 1.0,
recipes_used: Default::default(),
});
menu.drops.clear();
menu.equipped.clear();
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
world(&assets, &facts, false, true)?,
world(&assets, &facts, true, true)?,
];
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
for (ty, item) in [
(ParticleType::ItemSlime, "item_slime_ball"),
(ParticleType::ItemSnowball, "item_snowball"),
+50
View File
@@ -63,6 +63,10 @@ enum Animate {
SculkSensor,
/// Potent sulfur that is not dry.
PotentSulfur,
DriedGhast {
waterlogged: bool,
},
EndPortal,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -137,6 +141,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -180,6 +188,8 @@ pub struct AnimateTables {
impermeable: Vec<bool>,
power_provider: Vec<bool>,
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -237,6 +247,7 @@ impl AnimateTables {
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
ambient,
})
}
@@ -869,6 +880,45 @@ fn block_animate_tick(
];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::DriedGhast { waterlogged } => {
// DriedGhastBlock.animateTick.
let c = [fx + 0.5, fy + 0.5, fz + 0.5];
if waterlogged {
if next_int(engine.random(), 40) == 0 {
let name = "minecraft:block.dried_ghast.ambient_water";
sound(engine, name, c, 1.0, 1.0);
}
let r = engine.random();
if next_int(r, 6) == 0 {
let x = c[0] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
let y = c[1] + 0.4;
let z = c[2] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
let v = [0.0, f64::from(r.next_float()), 0.0];
add(engine, T::HappyVillager, [x, y, z], v);
}
} else {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
let triggers = || {
tables
.dried_ghast_sounds
.get(below.0 as usize)
.copied()
.unwrap_or(false)
};
if next_int(engine.random(), 40) == 0 && triggers() {
sound(engine, "minecraft:block.dried_ghast.ambient", c, 1.0, 1.0);
}
if next_int(engine.random(), 6) == 0 {
add(engine, T::WhiteSmoke, c, [0.0, 0.02, 0.0]);
}
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
+52
View File
@@ -44,6 +44,10 @@ pub struct Furnace {
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -190,6 +194,7 @@ impl BlockEntity {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
@@ -483,6 +488,14 @@ impl Furnace {
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
@@ -564,6 +577,7 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -594,3 +608,41 @@ fn total_time(time: u32, speed: f32) -> i32 {
time as i32
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
pub fn create_experience(
count: u32,
experience: f32,
random: &mut impl horizon_core::random::RandomSource,
) -> u32 {
let total = count as f32 * experience;
let whole = total.floor();
let fraction = total - whole;
let mut xp = whole.max(0.0) as u32;
if fraction != 0.0 && random.next_float() < fraction {
xp += 1;
}
xp
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
let mut random = LegacyRandom::new(7);
let mut untouched = LegacyRandom::new(7);
assert_eq!(create_experience(4, 0.5, &mut random), 2);
assert_eq!(create_experience(3, 0.0, &mut random), 0);
assert_eq!(random.next_long(), untouched.next_long());
let paid: u32 = (0..1000)
.map(|_| create_experience(1, 0.35, &mut random))
.sum();
assert!((250..450).contains(&paid), "{paid}");
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
}
}
+8
View File
@@ -1610,6 +1610,14 @@ impl Recipes {
}
out
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
pub fn cooking_experience(&self, id: &str) -> f32 {
self.cooking
.iter()
.find(|c| c.id == id)
.map_or(0.0, |c| c.experience)
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
+90 -2
View File
@@ -71,10 +71,52 @@ impl Names {
.cloned()
.unwrap_or_else(|| path.replace('_', " "))
}
/// An item's display name in the selected language: `item.*` then
/// `block.*`, then the pinned English map (`Names::item`).
pub fn display_item(&self, id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
if let Some(lang) = crate::lang::current() {
for prefix in ["item", "block"] {
if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
return name.to_owned();
}
}
}
self.item(id)
}
/// A translation key's text in the selected language, else the pinned
/// English map (`HoverName` and tooltip keys such as potion names).
pub fn display_get(&self, key: &str) -> Option<String> {
crate::lang::current()
.and_then(|lang| lang.get(key).map(str::to_owned))
.or_else(|| self.get(key).map(str::to_owned))
}
pub fn get(&self, key: &str) -> Option<&str> {
self.map.get(key).map(String::as_str)
}
}
/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
/// export's `title` is the English fallback.
pub fn item_group_key(id: &str) -> Option<&'static str> {
Some(match id.trim_start_matches("minecraft:") {
"building_blocks" => "itemGroup.buildingBlocks",
"colored_blocks" => "itemGroup.coloredBlocks",
"natural_blocks" => "itemGroup.natural",
"functional_blocks" => "itemGroup.functional",
"redstone_blocks" => "itemGroup.redstone",
"hotbar" => "itemGroup.hotbar",
"search" => "itemGroup.search",
"tools_and_utilities" => "itemGroup.tools",
"combat" => "itemGroup.combat",
"food_and_drinks" => "itemGroup.foodAndDrink",
"ingredients" => "itemGroup.ingredients",
"spawn_eggs" => "itemGroup.spawnEggs",
"op_blocks" => "itemGroup.op",
"inventory" => "itemGroup.inventory",
_ => return None,
})
}
impl Creative {
pub const WIDTH: f32 = 195.0;
pub const HEIGHT: f32 = 136.0;
@@ -303,7 +345,17 @@ impl Creative {
.collect();
}
// FullTextSearchTree: tooltip lines, and the item id after a colon.
// The export's lines are English (they come from the vanilla jars);
// the hover name in the selected language is matched too, so a
// localized search finds the item. It is the whole name (a tipped
// arrow's potion, the ominous banner), as the first line is.
let text = java_lower(term);
let localized = |i: usize| {
java_lower(&crate::item_tooltip::name_line(
&self.search_items[i],
&|k| names.display_get(k),
))
};
all.filter(|&i| {
let e = &self.search_index[i];
let fallback;
@@ -314,11 +366,15 @@ impl Creative {
&e.lines
};
match text.split_once(':') {
None => any(lines.iter().map(String::as_str), &text),
None => {
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
}
Some((ns, path)) => {
let (ns, path) = (java_trim(ns), java_trim(path));
e.namespace.contains(ns)
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
&& (e.path.contains(path)
|| any(lines.iter().map(String::as_str), path)
|| localized(i).contains(path))
}
}
})
@@ -759,6 +815,24 @@ mod tests {
assert_eq!(q("#craft"), Vec::<usize>::new());
}
#[test]
fn search_matches_the_display_name_in_the_selected_language() {
let c = creative(0);
let names = Names {
map: HashMap::from([
("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
]),
};
let q = |s: &str| c.search_results(s, &names);
assert_eq!(q("铁砧"), [0]);
assert_eq!(q("木锄"), [2]);
// The English tooltip lines the export indexed still match.
assert_eq!(q("anvil"), [0]);
// A colon's path searches the display name too.
assert_eq!(q("minecraft:铁砧"), [0]);
}
#[test]
fn scrollbar_drag_follows_the_mouse_while_held() {
let mut c = creative(90);
@@ -794,6 +868,20 @@ mod tests {
assert!(!c.scrolling);
}
#[test]
fn creative_tab_titles_have_vanilla_keys() {
assert_eq!(
item_group_key("minecraft:building_blocks"),
Some("itemGroup.buildingBlocks")
);
assert_eq!(
item_group_key("minecraft:food_and_drinks"),
Some("itemGroup.foodAndDrink")
);
assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
assert_eq!(item_group_key("minecraft:nonsense"), None);
}
#[test]
fn wheel_moves_one_row_per_notch() {
let mut c = creative(90);
+18 -8
View File
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.head_pitch = pitch;
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
if let Some(h) = float(tag.get("Health")) {
m.health = h as f32;
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
// the health, the max health when there is none.
if let Some(Tag::List(attrs)) = tag.get("attributes") {
for a in attrs {
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
_ => {}
}
}
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:jump_strength" => m.extra.vector[0] = v,
_ => {}
if id == "minecraft:jump_strength" {
m.extra.vector[0] = f64::from_bits(*bits);
}
}
}
+4 -1
View File
@@ -891,8 +891,11 @@ impl Font {
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
pub fn width_text(&self, t: &Text) -> f32 {
t.chars()
t.resolved()
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
+149 -34
View File
@@ -20,6 +20,7 @@ mod bossbars;
mod breezes;
mod cartography;
mod chat;
mod client_ambient;
mod clipboard;
mod combat;
mod command_blocks;
@@ -482,6 +483,9 @@ enum Screen {
CommandBlock,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
/// (game/mods.rs).
Mod,
}
struct Mining {
pos: [i32; 3],
@@ -537,6 +541,8 @@ struct GameLevel<'w> {
seed: i64,
behaviours: &'w Behaviours,
items: &'w Items,
/// Cooking recipes' experience, which a removed furnace pays out.
recipes: &'w horizon_client::crafting::Recipes,
/// Container loot tables (`unpackLootTable`).
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
registry: &'static horizon_world::registry::BlockRegistry,
@@ -1168,6 +1174,8 @@ struct Game<'a> {
carried: Option<ItemStack>,
craft_grid: Vec<Option<ItemStack>>,
craft_size: usize,
/// The crafting table the 3x3 grid belongs to (`CraftingMenu.access`).
craft_table: Option<[i32; 3]>,
/// A quick craft drag over inventory slots: (button, game slots so far).
inv_drag: Option<(i32, Vec<usize>)>,
/// `AbstractContainerScreen`'s quick craft and skip-release state.
@@ -1568,6 +1576,8 @@ struct Game<'a> {
cmds: commands::extra::CommandExtras,
ui: chat::UiState,
combat: combat::CombatTracker,
/// Each guest's own `CombatTracker`, out of `combat` between turns.
guest_combat: std::collections::HashMap<u32, combat::CombatTracker>,
mount: mounts::MountState,
stats: stats::Stats,
score: scoreboard::ScoreState,
@@ -1837,6 +1847,19 @@ impl Game<'_> {
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
})
}
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
/// `freeze_immune_wearables`.
pub(crate) fn wears_freeze_immune(&self) -> bool {
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
self.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
}
/// What the window decides for its player's body every tick, in frame and
/// on a server alike: the look (Player.travel steers sprint-swimming by
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
@@ -1863,16 +1886,7 @@ impl Game<'_> {
let leather_boots = self.armor[3]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
let freeze_immune = self
.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
let freeze_immune = self.wears_freeze_immune();
let Some(player) = self.player.as_mut() else {
return;
};
@@ -2173,6 +2187,11 @@ impl Game<'_> {
// Player.drop: the drop statistics.
self.stats.add("dropped", &thrown.item, thrown.count as i32);
self.stats.custom("drop", 1);
self.throw_from_eyes(thrown, eye);
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction.
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
@@ -2420,6 +2439,7 @@ impl Game<'_> {
seed: self.level.seed,
behaviours: self.res.behaviours(),
items: self.res.items(),
recipes: self.res.recipes(),
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
registry: self.pack.assets.registry,
rules,
@@ -2916,6 +2936,7 @@ impl Game<'_> {
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
self.container_opener_event(key, true);
@@ -4289,6 +4310,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::InsideWitherRose => self.wither_rose_contact(),
MotionEvent::SmashLanding {
state,
position,
@@ -4335,7 +4357,8 @@ impl Game<'_> {
// Out of the world.
let _ = player.set_health(0.0, false);
}
if player.health.dead_or_dying() && self.screen != Screen::Dead {
let dead = player.health.dead_or_dying();
if dead && self.screen != Screen::Dead {
// Inventory.dropAll: every stack thrown in a random direction.
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
@@ -4369,6 +4392,18 @@ impl Game<'_> {
}
self.screen = Screen::Dead;
self.capture(false);
self.ui.death_time = 0;
} else if dead {
// LivingEntity.tickDeath: 20 ticks after dying the player is
// removed, and Player.remove's inventoryMenu.removed drops what
// the cursor held.
self.ui.death_time += 1;
if self.ui.death_time >= 20
&& let Some(stack) = self.carried.take()
&& let Some(eye) = self.eye_position()
{
self.throw_from_eyes(stack, eye);
}
}
self.tick_world(running)
}
@@ -4447,6 +4482,7 @@ impl Game<'_> {
self.tick_block_entity_anims()
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
self.tick_client_ambient();
}
// ServerBossEvent broadcasts, after this level's entities have ticked.
self.publish_boss_bars();
@@ -5047,6 +5083,7 @@ impl Game<'_> {
self.draw_creative(&mut batch);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_toasts(&mut batch);
@@ -5304,22 +5341,32 @@ impl Game<'_> {
Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {}
Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy),
Some(BlockEntity::Furnace(f)) => {
let (tex, title) = match f.kind {
let (tex, key, fallback) = match f.kind {
horizon_client::containers::FurnaceKind::Furnace => {
("gui/container/furnace", "Furnace")
}
horizon_client::containers::FurnaceKind::BlastFurnace => {
("gui/container/blast_furnace", "Blast Furnace")
("gui/container/furnace", "container.furnace", "Furnace")
}
horizon_client::containers::FurnaceKind::BlastFurnace => (
"gui/container/blast_furnace",
"container.blast_furnace",
"Blast Furnace",
),
horizon_client::containers::FurnaceKind::Smoker => {
("gui/container/smoker", "Smoker")
("gui/container/smoker", "container.smoker", "Smoker")
}
};
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
let title = &self.container_title(title.to_owned());
let tw = HudBatch::text_width(atlas, title);
batch.text(atlas, title, ox + (176.0 - tw) / 2.0, oy + 6.0, grey, false);
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
let title = self.container_title(self.translate(key, fallback));
let tw = HudBatch::text_width(atlas, &title);
batch.text(
atlas,
&title,
ox + (176.0 - tw) / 2.0,
oy + 6.0,
grey,
false,
);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
if f.lit_remaining > 0 {
// AbstractFurnaceScreen: flame height ceil(lit * 13) + 1.
let total = if f.lit_total == 0 { 200 } else { f.lit_total };
@@ -5373,14 +5420,12 @@ impl Game<'_> {
"gui/container/inventory"
};
part(batch, texture, 0.0, 0.0, 176.0, 166.0, ox, oy);
let (title, tx) = if self.craft_size == 3 {
("Crafting", 29.0)
} else {
("Crafting", 97.0)
};
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
let title = self.translate("container.crafting", "Crafting");
let tx = if self.craft_size == 3 { 29.0 } else { 97.0 };
batch.text(atlas, &title, ox + tx, oy + 6.0, grey, false);
if self.craft_size == 3 {
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
}
}
}
@@ -5425,6 +5470,7 @@ impl Game<'_> {
}
}
self.craft_size = 2;
self.craft_table = None;
if let Some(key) = self.container.take() {
self.container_opener_event(key, false);
self.post_block_game_event(
@@ -5936,6 +5982,12 @@ impl Game<'_> {
// Attached after the slots were set: setting them is not a click.
menu.merchant = session.map(|(s, _)| s);
}
// `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot.
let furnace_before = if matches!(kind, Kind::Furnace(_)) {
menu.slot(2).cloned().map_or(0, |s| s.count)
} else {
0
};
let outcome = run(&mut menu, &env, &to_menu);
let made = maps.made.take();
match outcome {
@@ -5953,6 +6005,12 @@ impl Game<'_> {
self.merchant_sync_out(s);
}
let after: Vec<Option<ItemStack>> = (0..total).map(|i| menu.slot(i).cloned()).collect();
// Only a furnace has a result slot to compare: an enchanting table has two slots.
let furnace_after = if matches!(kind, Kind::Furnace(_)) {
after[2].as_ref().map_or(0, |s| s.count)
} else {
0
};
let carried = menu.carried.take();
let dropped = std::mem::take(&mut menu.dropped);
let sounds = std::mem::take(&mut menu.sounds);
@@ -5977,6 +6035,13 @@ impl Game<'_> {
self.player_drop(stack);
}
self.award_crafted(&crafted);
if furnace_before > furnace_after
&& self.remote.is_none()
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
{
let at = [p.position.x, p.position.y, p.position.z];
self.award_furnace_recipes(pos, at);
}
}
if kind == Kind::Loom {
if let Some(s) = self.station.as_mut() {
@@ -7879,8 +7944,12 @@ impl Game<'_> {
(0..width).all(|i| frame([left[0] + d[0] * i, left[1] + height, left[2] + d[2] * i]));
((3..=21).contains(&height) && top_ok).then_some((left, width, height))
}
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal.
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal,
/// only in the Overworld and the Nether (`inPortalDimension`).
fn try_light_portal(&mut self, at: [i32; 3]) -> Result<bool> {
if !matches!(self.dim, Dim::Overworld | Dim::Nether) {
return Ok(false);
}
for x_axis in [true, false] {
if let Some((origin, width, height)) = self.portal_shape(at, x_axis) {
let d = if x_axis { [1, 0, 0] } else { [0, 0, 1] };
@@ -8768,6 +8837,27 @@ impl Game<'_> {
self.give_orb_points(gained);
}
}
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
/// furnace at `pos`, which pops the experience of what it made at them
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
let used = match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::Furnace(f)) => {
let used = f.recipe_experience(self.res.recipes());
f.recipes_used.clear();
used
}
_ => return,
};
for (count, experience) in used {
let xp = horizon_client::containers::create_experience(
count,
experience,
&mut self.world.random,
);
self.spawn_orbs(at, xp);
}
}
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
@@ -9411,6 +9501,11 @@ impl Game<'_> {
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
}
/// A creative tab's title: `itemGroup.*`, else the export's English title.
fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
horizon_client::creative::item_group_key(&tab.id)
.map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title))
}
/// Vanilla tooltip: dark box with a purple gradient border.
fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
let atlas = &self.gui.atlas;
@@ -9480,9 +9575,10 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
if !tab.inventory {
let title = self.creative_tab_title(&tab);
batch.text(
atlas,
&tab.title,
&title,
ox + 8.0,
oy + 6.0,
rgba(64, 64, 64, 255),
@@ -9593,7 +9689,12 @@ impl Game<'_> {
&& self.cursor.1 >= oy + ty
&& self.cursor.1 < oy + ty + 32.0
{
self.tooltip(batch, &t.title, self.cursor.0, self.cursor.1);
self.tooltip(
batch,
&self.creative_tab_title(t),
self.cursor.0,
self.cursor.1,
);
}
}
self.draw_carried(batch);
@@ -10363,6 +10464,7 @@ impl Game<'_> {
Screen::CommandBlock => self.close_command_block(),
Screen::Chat => self.close_chat(),
Screen::Win => self.close_win_screen()?,
Screen::Mod => self.close_mod_screen(true),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -11009,6 +11111,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Options && d != 0.0 {
self.options_scroll(d);
}
if self.screen == Screen::Mod && d != 0.0 {
self.mod_screen_scroll(d);
}
if self.screen == Screen::Creative && d != 0.0 {
self.creative_screen.wheel(f64::from(d), &self.pack.names);
}
@@ -11231,6 +11336,9 @@ 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()
}
_ => {}
}
}
@@ -11272,9 +11380,10 @@ impl ApplicationHandler for Game<'_> {
// The hovered slot took the key (`ignoreTextInput`).
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
self.creative_search_backspace(self.has_control_down());
} else {
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
self.creative_search_edit(event.text.as_deref(), backspace);
self.creative_search_edit(event.text.as_deref());
}
}
WindowEvent::KeyboardInput { event, .. }
@@ -11709,7 +11818,11 @@ impl<'a> Game<'a> {
let inventory = vec![None; 36];
let now = Instant::now();
let look = options.look;
let particles = horizon_client::particles::ParticleEngine::new(&root, &gui.atlas.sprites);
let particles = horizon_client::particles::ParticleEngine::new(
&root,
&gui.atlas.sprites,
&gui.atlas.translucent,
);
Ok(Game {
res: res.clone(),
rest_loader: None,
@@ -11746,6 +11859,7 @@ impl<'a> Game<'a> {
carried: None,
craft_grid: vec![None; 9],
craft_size: 2,
craft_table: None,
inv_drag: None,
tweaks: Default::default(),
inv_clicks: Default::default(),
@@ -12001,6 +12115,7 @@ impl<'a> Game<'a> {
cmds: Default::default(),
ui: Default::default(),
combat: Default::default(),
guest_combat: Default::default(),
mount: Default::default(),
stats: Default::default(),
score: Default::default(),
+7 -2
View File
@@ -416,6 +416,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -1599,8 +1600,12 @@ impl Game<'_> {
if self.screen != Screen::Dead {
return Err("respawn needs the death screen".into());
}
let label =
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
let key = if self.level.hardcore {
"deathScreen.spectate"
} else {
"deathScreen.respawn"
};
let label = horizon_client::text::Text::translate(key, &[]).plain();
let w = widgets::find(&widgets::last(), &label)?;
if !w.enabled {
// DeathScreen enables its buttons after 20 ticks.
+6 -1
View File
@@ -519,7 +519,12 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
self.draw_tooltip_lines(
batch,
&[(horizon_client::text::Text::literal(text), u32::MAX)],
self.cursor.0,
self.cursor.1,
);
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
@@ -15,6 +15,8 @@ use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
self.tick_mount_menu();
self.tick_craft_table();
self.tick_container_reach();
self.load_entity_types();
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
@@ -13,7 +13,7 @@ use horizon_client::{
use horizon_core::geometry::{Aabb, Vec3};
/// `Entity.boardingCooldown` once a mob leaves its boat.
const BOARDING_COOLDOWN: i32 = 60;
pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
+4 -2
View File
@@ -18,7 +18,7 @@ fn chat_tree<'a>(
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => tree,
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
None => mods.dispatcher(),
}
}
@@ -99,6 +99,8 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
impl Game<'_> {
@@ -586,7 +588,7 @@ impl Game<'_> {
} else {
let line = Text::translate(
"chat.type.text",
&[Text::literal(PLAYER_NAME), Text::literal(message)],
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
@@ -0,0 +1,37 @@
//! The window's own ambient effects of entities, which a client makes for
//! itself (`level().isClientSide()` branches): endermen's and endermites'
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
if self.serving.is_some() || self.options.dedicated.is_some() {
return;
}
let mut events = Vec::new();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
}
for event in events {
match event {
MobEvent::ParticleAt { kind, at, velocity } => {
if let Some(ty) = ParticleType::from_name(kind) {
self.particles
.add(&ParticleOptions::simple(ty), at, velocity);
}
}
// Each guest of a LAN host makes its own.
MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
}
_ => {}
}
}
self.eye_particles();
}
}
+16 -6
View File
@@ -97,15 +97,25 @@ impl Game<'_> {
self.creative_screen.scroll = 0.0;
}
}
/// Typing or a backspace in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>, backspace: bool) {
/// A backspace in the creative search box: with Ctrl, the whole word
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
let search = &mut self.creative_screen.search;
let cleared = self.text_selection.take(CREATIVE_SEARCH, search);
if backspace {
if !cleared {
if !self.text_selection.take(CREATIVE_SEARCH, search) {
if ctrl {
horizon_client::ui::delete_words(search, -1);
} else {
search.pop();
}
} else if let Some(text) = typed {
}
self.creative_screen.scroll = 0.0;
}
/// Typing in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
let search = &mut self.creative_screen.search;
self.text_selection.take(CREATIVE_SEARCH, search);
if let Some(text) = typed {
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
}
self.creative_screen.scroll = 0.0;
+5 -1
View File
@@ -191,8 +191,12 @@ impl Game<'_> {
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
}
/// `CombatTracker.getDeathMessage` for a player whose display name is
/// `me`: a server's guest is not `PLAYER_NAME`.
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
self.recheck_combat();
let me = Text::literal(PLAYER_NAME);
let entries = std::mem::take(&mut self.combat.entries);
self.combat.in_combat = false;
let Some(killing) = entries.last() else {
+5 -5
View File
@@ -214,7 +214,7 @@ impl Game<'_> {
}
/// Player.getDisplayName: the name (team-formatted), suggesting a
/// `/tell`, showing the player on hover, inserting the name.
fn player_display_name(&self) -> Text {
pub(super) fn player_display_name(&self) -> Text {
let name = self.acting_player_name().to_owned();
let Some(uuid) = self.lan_acting_uuid else {
return self.target_name(Target::Player);
@@ -444,7 +444,7 @@ impl Game<'_> {
.find(|i| i.id == id)
.map(|i| i.stack.item.to_string())
.unwrap_or_default();
Text::literal(self.pack.names.item(&item))
Text::literal(self.pack.names.display_item(&item))
}
Target::Mob(id)
if self
@@ -2809,7 +2809,7 @@ impl Game<'_> {
let name = match self.command_source.map(|s| s.entity) {
Some(Some(t)) => self.target_name(t),
Some(None) => Text::literal("Server"),
None => Text::literal(self.acting_player_name()),
None => self.player_display_name(),
};
let line = match id {
"say" => Text::translate("chat.type.announcement", &[name, message]),
@@ -2819,14 +2819,14 @@ impl Game<'_> {
// Whispering to yourself: the incoming and outgoing lines.
let out = Text::translate(
"commands.message.display.outgoing",
&[Text::literal(self.acting_player_name()), message.clone()],
&[self.player_display_name(), message.clone()],
)
.with_color(text::GRAY)
.italic();
self.system_message(out);
Text::translate(
"commands.message.display.incoming",
&[Text::literal(self.acting_player_name()), message],
&[self.player_display_name(), message],
)
.with_color(text::GRAY)
.italic()
@@ -606,10 +606,7 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
Some("vehicle") => match entity {
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
vec![Target::Player]
@@ -1074,15 +1074,15 @@ impl Game<'_> {
};
let name = self.target_name(target);
if id == "ride.dismount" {
let vehicle = match target {
Target::Player => self.riding.map(Target::Mob),
_ => None,
};
let Some(v) = vehicle else {
let Some(v) = self.ride_vehicle(target) else {
return Err(err("commands.ride.not_riding", &[&name.plain()]));
};
let vehicle_name = self.target_name(v);
self.dismount_mount();
match target {
Target::Player => self.dismount_mount(),
Target::Mob(rider) => self.dismount_mob(rider),
_ => {}
}
self.feedback(Text::translate(
"commands.ride.dismount.success",
&[name, vehicle_name],
@@ -1094,11 +1094,8 @@ impl Game<'_> {
return Ok(0);
};
let vehicle_name = self.target_name(vehicle);
if target == Target::Player && self.riding.is_some() {
let current = self
.riding
.map(Target::Mob)
.map_or_else(Text::empty, |v| self.target_name(v));
if let Some(current) = self.ride_vehicle(target) {
let current = self.target_name(current);
return Err(err(
"commands.ride.already_riding",
&[&name.plain(), &current.plain()],
@@ -1107,22 +1104,42 @@ impl Game<'_> {
if vehicle == Target::Player {
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
}
if target == vehicle {
return Err(err("commands.ride.mount.failure.loop", &[]));
// getSelfAndPassengers: the vehicle is the target or rides it.
let mut up = Some(vehicle);
while let Some(v) = up {
if v == target {
return Err(err("commands.ride.mount.failure.loop", &[]));
}
up = self.ride_vehicle(v);
}
// The player can ride mobs that carry a rider.
// Players and mobs can ride mobs.
let Target::Mob(mob_id) = vehicle else {
return Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
};
if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
let rider = match target {
Target::Mob(id) => Some(id),
_ => None,
};
if !(target == Target::Player || rider.is_some())
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
{
return Err(err(
"commands.ride.mount.failure.generic",
&[&name.plain(), &vehicle_name.plain()],
));
}
if let Some(rider) = rider {
// Entity.startRiding.
self.mount_mob(rider, mob_id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
m.extra.flag = false;
m.extra.counter = 0;
@@ -1138,6 +1155,47 @@ impl Game<'_> {
Ok(1)
}
/// Entity.getVehicle: the player's mount, or a mob's.
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
let id = match t {
Target::Player => return self.riding.map(Target::Mob),
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
_ => return None,
};
Some(if self.boats.iter().any(|b| b.id == id) {
Target::Boat(id)
} else {
Target::Mob(id)
})
}
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
let Some(v) = self.mobs[i].extra.mount.take() else {
return;
};
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
self.boat_dismount_target(b, width, yaw)
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
m.extra.rider = None;
// Entity.getDismountLocationForPassenger: on top of the vehicle.
let p = m.position;
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
} else {
self.mobs[i].position
};
let m = &mut self.mobs[i];
m.extra.passenger = false;
m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN;
m.position = at;
m.previous = at;
}
// ---- /spreadplayers -----------------------------------------------------------------------
pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R<i32> {
+2 -1
View File
@@ -569,7 +569,7 @@ impl Game<'_> {
}
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text), false)
self.creative_search_edit(Some(text))
}
_ => {
return Err(
@@ -799,6 +799,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
+35 -2
View File
@@ -321,7 +321,7 @@ impl Game<'_> {
.iter()
.filter(|m| m.distance_sqr(center) <= radius * radius)
.map(|m| {
serde_json::json!({
let mut state = serde_json::json!({
"id": m.id,
"kind": m.kind.id,
"pos": [m.position.x, m.position.y, m.position.z],
@@ -370,7 +370,11 @@ impl Game<'_> {
"patrol_target": s.patrol_target,
"vehicle": m.extra.mount,
})),
})
});
// Drawn seated (Entity.isPassenger); set apart because the
// json! above is at the macro recursion limit.
state["passenger"] = m.extra.passenger.into();
state
})
.collect();
serde_json::json!({ "count": self.mobs.len(), "near": list, "projectiles": self.creatures.projectiles.len() })
@@ -1558,6 +1562,16 @@ impl Game<'_> {
id, ticks, amplifier,
));
}
/// `WitherRoseBlock.entityInside` for the player: on a server level that
/// isn't peaceful, `getBeeInteractionEffect` (Wither, 40 ticks) unless the
/// player is invulnerable to wither damage (creative, spectator). A
/// server's player gets it from the server.
pub(super) fn wither_rose_contact(&mut self) {
if self.remote.is_some() || self.ui.difficulty == 0 {
return;
}
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);
@@ -1694,7 +1708,26 @@ impl Game<'_> {
targets: targets_guests,
};
let guest_entities = self.lan_guest_entities();
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
// place of its tick while ender_pearls_vanish_on_death is on.
let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death");
let host_dead = self.player_view().is_some_and(|p| !p.alive);
let dead_guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.dead)
.map(|g| g.conn)
.collect()
});
projectiles.retain_mut(|p| {
if vanish
&& matches!(p.kind, ProjectileKind::EnderPearl)
&& p.owner == Some(Who::Player)
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
{
return false;
}
let mob_shot = !matches!(p.owner, Some(Who::Player));
// A player's arrows, snowballs and eggs meet every player; a
// guest's flies from that guest (Projectile.getOwner), not
+20 -12
View File
@@ -336,6 +336,12 @@ enum StrafeRequest {
/// Anything else: the player nearest the crystal.
Nearest(Vec3),
}
/// Whether `EndCrystal.hurtServer` creates a blast before notifying the fight.
#[derive(Clone, Copy, PartialEq)]
enum CrystalDamage {
Direct,
Explosion,
}
/// What `EnderDragon.hurt` made of a hit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Hurt {
@@ -867,27 +873,29 @@ impl Game<'_> {
}
}
}
/// A crystal took damage: `EndCrystal.hurtServer` blows it up (power 6).
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>) {
/// `EndCrystal.hurtServer`: remove it, blast only for non-explosion damage,
/// then notify the dragon fight even when no blast was created.
fn destroy_crystal(&mut self, index: usize, by: Option<FightWho>, damage: CrystalDamage) {
if index >= self.crystals.len() || self.crystals[index].invulnerable {
return;
}
let c = self.crystals.remove(index);
if self.fight.respawn.is_some()
// Read before the blast, which can take other respawn crystals.
let respawn_crystal = self.fight.respawn.is_some()
&& self
.fight
.respawn_crystals
.iter()
.any(|p| p.distance_squared(c.position) < 0.01)
{
.any(|p| p.distance_squared(c.position) < 0.01);
if damage == CrystalDamage::Direct {
let at = [c.position.x, c.position.y, c.position.z];
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
}
if respawn_crystal {
self.abort_respawn();
return;
}
let at = [c.position.x, c.position.y, c.position.z];
let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
// EnderDragonFight.onCrystalDestroyed.
self.count_crystals();
if let Some(d) = self.dragon.as_mut() {
@@ -962,7 +970,7 @@ impl Game<'_> {
return Hurt::Refused;
}
let by = by_player.then(|| self.acting_player());
self.destroy_crystal(index, by);
self.destroy_crystal(index, by, CrystalDamage::Direct);
Hurt::Landed { full: true }
} else if id >= SPECIAL {
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
@@ -2634,8 +2642,8 @@ impl Crystal {
}
}
impl Game<'_> {
/// `ServerExplosion.hurtEntities` on the end crystals (each in reach
/// blows up in turn) and the dragon (explosions always hurt dragons).
/// `ServerExplosion.hurtEntities`: crystals in reach are removed without
/// another blast; explosions always hurt dragons.
pub(super) fn explosion_hits_special(&mut self, center: [f64; 3], radius: f32) {
let diameter = f64::from(radius * 2.0);
let in_reach = |p: Vec3| {
@@ -2651,9 +2659,9 @@ impl Game<'_> {
.map(|c| c.position)
.collect();
for at in crystals {
// One blowing up may already have taken the next.
// Look up the current index after each removal.
if let Some(i) = self.crystals.iter().position(|c| c.position == at) {
self.destroy_crystal(i, None);
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
let Some(d) = self.dragon.as_ref() else {
+35 -1
View File
@@ -4,7 +4,12 @@
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::{
Result, environment::Environment, items::ItemStack, mesher::Vertex, motion::PlayerMotion,
Result,
environment::Environment,
items::ItemStack,
mesher::Vertex,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
vanillagen::VanillaTerrain,
};
use horizon_core::{
@@ -1122,6 +1127,35 @@ impl Game<'_> {
self.end.eyes = eyes;
}
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
/// move back), four bubbles in water, else a portal particle.
pub(super) fn eye_particles(&mut self) {
for i in 0..self.end.eyes.len() {
let e = &self.end.eyes[i];
let movement = (e.position - e.previous).to_array();
let origin = (e.position - (e.position - e.previous) * 0.25).to_array();
let block = origin.map(|v| v.floor() as i32);
let water = self
.res
.facts()
.get(self.world.block(block[0], block[1], block[2]))
.fluid
== horizon_client::blocks::Fluid::Water;
if water {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..4 {
self.particles.add(&bubble, origin, movement);
}
} else {
let r = self.particles.random();
let x = origin[0] + r.next_double() * 0.6 - 0.3;
let z = origin[2] + r.next_double() * 0.6 - 0.3;
let portal = ParticleOptions::simple(ParticleType::Portal);
self.particles
.add(&portal, [x, origin[1] - 0.5, z], movement);
}
}
}
/// ThrownItemRenderer: the ender eye item facing the camera.
fn eye_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let Some(r) = self.gui.atlas.sprites.get("item/ender_eye").copied() else {
+38 -5
View File
@@ -25,9 +25,15 @@ pub(super) struct Rocket {
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
explosions: Vec<FireworkExplosion>,
pub(super) explosions: Vec<FireworkExplosion>,
/// Boosting the player (attachedToEntity).
pub(super) attached: bool,
/// On a server, the player it boosts when not this game's own: a guest's
/// connection.
pub(super) guest: Option<u32>,
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
/// boosting happens when it is the local player.
pub(super) attached_to: Option<u32>,
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
/// the first block it hits.
pub(super) angled: bool,
@@ -48,6 +54,8 @@ impl Rocket {
lifetime: i32::MAX,
explosions,
attached: false,
guest: None,
attached_to: None,
angled: false,
item,
}
@@ -63,7 +71,7 @@ impl Rocket {
(self.position, self.velocity)
}
}
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
// The item's default component: flight 1, no explosions.
stack
.data()
@@ -89,6 +97,8 @@ impl Game<'_> {
lifetime,
explosions,
attached,
guest: None,
attached_to: None,
angled: false,
item: stack.clone(),
}
@@ -127,7 +137,8 @@ impl Game<'_> {
return false;
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let rocket = self.new_rocket(at, stack, true);
let mut rocket = self.new_rocket(at, stack, true);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
if !self.creative {
self.consume_held();
@@ -140,7 +151,18 @@ impl Game<'_> {
while i < self.rockets.len() {
let mut r = self.rockets.remove(i);
r.previous = r.position;
if r.attached {
if r.attached
&& let Some(conn) = r.guest
{
// A guest's rocket follows them; their own client boosts them.
let guest = self
.lan
.as_ref()
.and_then(|host| host.hub.guests.iter().find(|g| g.conn == conn));
if let Some(g) = guest {
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
}
} else if r.attached {
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
@@ -229,8 +251,19 @@ impl Game<'_> {
let at = [r.position.x, r.position.y, r.position.z];
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
// The player it boosts takes the full damage, a guest as well.
if r.attached {
self.hurt_player_by("minecraft:fireworks", damage);
match r.guest {
None => self.hurt_player_by("minecraft:fireworks", damage),
Some(conn) => {
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| {
g.hurt_player_by("minecraft:fireworks", damage);
});
self.lan = Some(host);
}
}
}
}
let from = r.position;
let hits: Vec<(u32, f32)> = {
+42 -5
View File
@@ -55,8 +55,8 @@ pub(super) struct LanHost {
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt and death state of the mobs guests see.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
@@ -162,17 +162,28 @@ fn mob_meta(
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the off
// hand 2 (a skeleton draws its bow, a witch raises her potion).
let using = i8::from(m.extra.using_item > 0) | i8::from(m.extra.using_off_hand) << 1;
if using != 0 {
meta.push((8, serializer::BYTE, Value::Byte(using)));
}
defaults.push((8, serializer::BYTE, Value::Byte(0)));
// LivingEntity.DATA_HEALTH_ID: defined as 1, then set to the max health.
let health = m.health.max(0.0);
if health != 1.0 {
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
// weapon).
let flags =
i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1 | i8::from(m.ai.aggressive) << 2;
if flags != 0 {
meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((15, serializer::BYTE, Value::Byte(0)));
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
if let Some(index) = baby_index(kind) {
if m.is_baby() {
@@ -289,6 +300,21 @@ fn mob_meta(
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
}
// Pillager.IS_CHARGING_CROSSBOW: the charge pose and the held
// item's loading animation.
"minecraft:pillager" => {
if e.flag {
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
"minecraft:evoker" | "minecraft:illusioner" => {
if e.variant2 != 0 {
meta.push((17, serializer::BYTE, Value::Byte(e.variant2 as i8)));
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
}
// Enderman.DATA_CARRY_STATE.
"minecraft:enderman" => {
if let Some(state) = e.carried {
@@ -979,6 +1005,7 @@ impl Game<'_> {
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::log(
"INFO",
@@ -1193,7 +1220,17 @@ impl Game<'_> {
if dead && !old.is_some_and(|o| o.1) {
host.hub.entity_event(m.id as i32, 3, at);
}
seen.insert(m.id, (m.hurt_time, dead));
// LivingEntity.swing on a server (a melee hit, a thrown potion):
// ClientboundSwingAnimationPacket for the players around. A swing
// started since the last flush, or restarted (its time went back).
let swing = m.ai.swinging.then_some(m.ai.swing_time);
if let Some(time) = swing
&& !dead
&& old.and_then(|o| o.2).is_none_or(|before| time < before)
{
host.hub.swing(m.id as i32, 0, at, None);
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
}
+155 -43
View File
@@ -117,6 +117,7 @@ pub(super) struct GuestSwap {
host_command_block: Option<super::command_blocks::CommandBlockScreen>,
host_mount_menu: super::mount_menu::MountMenu,
craft_size: usize,
craft_table: Option<[i32; 3]>,
craft_grid: Vec<Option<ItemStack>>,
container: Option<(i32, i32, i32)>,
mining: Option<Mining>,
@@ -143,6 +144,8 @@ pub(super) struct GuestSwap {
adv_loaded: bool,
last_inventory: u64,
death_cause: Option<String>,
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
/// The guest whose turn this one nests in, if any.
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
@@ -256,6 +259,14 @@ impl Game<'_> {
host.play.insert(conn, play);
return None;
};
// 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 {
Default::default()
} else {
self.guest_combat.remove(&conn).unwrap_or_default()
};
let host_combat = std::mem::replace(&mut self.combat, guest_combat);
self.potions.swap_player(&mut g.effects, &mut play.potions);
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
motion.ground.on_ground = g.on_ground;
@@ -264,6 +275,8 @@ impl Game<'_> {
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
motion.invulnerable = motion.may_fly;
let _ = motion.set_health(g.health, false);
motion.health.food.food = g.food;
@@ -307,6 +320,7 @@ impl Game<'_> {
// The host's crafting grid stays theirs; a crafting table the guest
// opens shows as the 3x3 size.
let craft_size = std::mem::replace(&mut self.craft_size, 2);
let craft_table = self.craft_table.take();
let craft_grid = std::mem::replace(&mut self.craft_grid, vec![None; 9]);
let container = self.container.take();
let mining = std::mem::replace(&mut self.mining, play.mining.take());
@@ -390,6 +404,7 @@ impl Game<'_> {
host_command_block,
host_mount_menu,
craft_size,
craft_table,
craft_grid,
container,
mining,
@@ -418,6 +433,7 @@ impl Game<'_> {
swings,
projectiles,
modifiers,
combat: host_combat,
})
}
@@ -445,6 +461,7 @@ impl Game<'_> {
host_command_block,
host_mount_menu,
craft_size,
craft_table,
craft_grid,
container,
mining,
@@ -468,12 +485,16 @@ impl Game<'_> {
adv_loaded,
last_inventory,
death_cause,
combat,
acting,
packets,
swings,
projectiles,
modifiers,
} = swap;
// 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);
play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers);
// What the guest shot is theirs (Projectile.setOwner).
for p in self.creatures.projectiles.iter_mut().skip(projectiles) {
@@ -495,7 +516,10 @@ impl Game<'_> {
// What the action opened: handed to the guest instead of the host.
let opened = self.container.take();
let opened_table = std::mem::replace(&mut self.craft_size, craft_size) == 3;
let guest_table = std::mem::replace(&mut self.craft_table, craft_table);
let opened_table = (std::mem::replace(&mut self.craft_size, craft_size) == 3)
.then_some(guest_table)
.flatten();
self.craft_grid = craft_grid;
let opened_station = std::mem::replace(&mut self.station, host_station);
let bed = std::mem::replace(&mut self.sleeping, sleeping);
@@ -671,8 +695,8 @@ impl Game<'_> {
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
if opened_table {
self.lan_open_crafting(host, conn);
if let Some(pos) = opened_table {
self.lan_open_crafting(host, conn, pos);
}
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
@@ -812,6 +836,31 @@ impl Game<'_> {
}
}
/// `ServerPlayer.tick`'s `stillValid`: a guest's block container closes
/// once the guest is out of reach of its block (a removed block closes it
/// in the hub's `sync_containers`).
fn lan_containers_tick(&mut self, host: &mut LanHost) {
let far: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
})
})
.map(|g| g.conn)
.collect();
for conn in far {
self.guest_container_closed(host, conn);
host.hub.close_container(conn);
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
self.guest_carried_back(host, gi);
}
}
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1075,7 +1124,20 @@ impl Game<'_> {
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
let slots = host.hub.guests[gi]
.menu
.as_ref()
.filter(|m| m.window == p.container)
.map(|m| m.slots);
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
// A click that changed the container's slots: BlockEntity.setChanged,
// so comparators read it again (a double chest's other half follows).
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
&& slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi])) != before
{
self.container_changed(key);
}
self.drop_guest_items(host, gi, drops);
}
S::ContainerClose(p) => {
@@ -1216,29 +1278,35 @@ impl Game<'_> {
host: &LanHost,
stack: &Option<horizon_protocol::item::Stack>,
) -> Option<ItemStack> {
use horizon_protocol::item::{Component, component};
let s = stack.as_ref()?;
let name = host
.hub
.item_name(s.item)
.unwrap_or_else(|| "minecraft:air".into());
let mut out = ItemStack::new(&name, s.count.max(1) as u32);
out.damage = s.damage().max(0) as u32;
if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) {
for (id, level) in list {
if let Some(e) = host.hub.enchantment_name(*id) {
out.enchant(&e, *level as u32);
}
}
}
let mut out = host.hub.stack_from_network(s);
out.count = out.count.max(1);
Some(out)
}
/// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed,
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
/// sooner removes it too.
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
g.death_time += 1;
if g.death_time >= 20 {
self.guest_drop_carried(host, gi);
}
}
fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
}
/// `AbstractContainerMenu.removed`: what a closing player held on the
/// cursor goes back into their inventory (`placeItemBackInInventory`,
/// dropped only if it doesn't fit), and their client's cursor empties
/// with the inventory it is sent.
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
pub(super) fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.lan_place_back(host, gi, vec![c]);
host.hub.send_inventory(gi);
@@ -1573,6 +1641,8 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn removes the dead player (Player.remove).
self.guest_drop_carried(host, i);
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
// or another one's anchor or forced point, which its block resolves
@@ -1613,6 +1683,7 @@ impl Game<'_> {
}
let g = &mut host.hub.guests[i];
g.dead = false;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
g.saturation = 5.0;
@@ -1649,6 +1720,11 @@ impl Game<'_> {
border: &self.border,
};
host.hub.respawn(i, spawn, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.level.hardcore {
host.hub.set_game_mode(conn, SPECTATOR);
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
}
@@ -1680,6 +1756,8 @@ impl Game<'_> {
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -1694,7 +1772,11 @@ impl Game<'_> {
continue;
};
self.guest_sleep_tick(host, gi);
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
if host.hub.guests[gi].dead {
self.guest_tick_death(host, gi);
continue;
}
if self.on_server_trip(conn) {
continue;
}
let play = host.play.entry(conn).or_default();
@@ -1792,31 +1874,34 @@ impl Game<'_> {
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
let message = match cause {
// ServerPlayer.die: the death message, to everyone unless
// the player's team hides it.
Some(key) if self.ui.rules.bool("show_death_messages") => {
let text = horizon_client::text::Text::translate(
&key,
&[self.player_text(&name, uuid)],
);
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log(
"INFO",
&format!("System chat: {}", text.plain()),
);
} else if self.player.is_some() {
self.system_message(text);
}
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
let message = if self.ui.rules.bool("show_death_messages") {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log(
"INFO",
&format!("System chat: {}", text.plain()),
);
} else if self.player.is_some() {
self.system_message(text);
}
message
}
Some(_) => literal(""),
None => literal(&format!("{name} died")),
message
} else {
literal("")
};
host.hub.send_to(
conn,
@@ -1832,6 +1917,21 @@ impl Game<'_> {
}
}
/// `ServerPlayer.die`'s message from a guest's own `CombatTracker`, which
/// sits in `guest_combat` between turns.
fn guest_death_message(
&mut self,
conn: u32,
me: horizon_client::text::Text,
) -> horizon_client::text::Text {
let mut tracker = self.guest_combat.remove(&conn).unwrap_or_default();
std::mem::swap(&mut self.combat, &mut tracker);
let text = self.combat_death_message_for(me);
std::mem::swap(&mut self.combat, &mut tracker);
self.guest_combat.insert(conn, tracker);
text
}
/// `Player.tick`'s sleep counter, and `LivingEntity.tick`'s wake-up of a
/// sleeper who died or whose bed is gone.
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
@@ -1942,6 +2042,7 @@ impl Game<'_> {
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let freeze_immune = game.wears_freeze_immune();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2005,11 +2106,18 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
let mut events = std::mem::take(&mut w.player.events);
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
game.wither_rose_contact();
}
}
// ServerGamePacketListenerImpl.handleMovePlayer:
// Player.checkMovementStatistics for each move.
for d in travelled {
@@ -2033,10 +2141,14 @@ impl Game<'_> {
resources: game.motion,
}
.server_travel();
// Landing damage from this travel reaches the tracker too.
events.append(&mut player.events);
// A hurt since the last tick marked the velocity (Entity.markHurt):
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
(events, marked.then_some(player.velocity))
let velocity = marked.then_some(player.velocity);
game.record_combat(&events);
(events, velocity)
});
let Some((events, marked)) = events else {
return;
+62 -14
View File
@@ -19,8 +19,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
pub(super) struct GuestCrafting {
/// The inventory menu; its grid lives in the hub's guest.
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id and menu.
table: Option<(i32, i32, Box<Menu>)>,
/// The open crafting table: its window, state id, menu and block.
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
@@ -225,6 +225,7 @@ impl Game<'_> {
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
_ => return false,
};
let furnace = matches!(kind, Kind::Furnace(_));
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
let crafter_data = match self.world.block_entities.get(&pos) {
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
@@ -284,6 +285,12 @@ impl Game<'_> {
}
_ => {}
}
// FurnaceResultSlot.checkTakeAchievements: the guest took from the output.
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
let at = host.hub.guests[gi].position;
self.award_furnace_recipes(pos, at);
}
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
host.hub.inventory_clicked(conn, p, &self.world, &changed);
true
@@ -383,17 +390,17 @@ impl Game<'_> {
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu)) = host
let Some((window, state, mut menu, pos)) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
placed
};
@@ -403,7 +410,7 @@ impl Game<'_> {
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
@@ -413,7 +420,7 @@ impl Game<'_> {
return;
};
let menu = Box::new(Menu::new(Kind::Crafting));
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu, pos));
self.lan_send_table_content(host, conn);
}
@@ -427,31 +434,72 @@ impl Game<'_> {
let Some(play) = host.play.get_mut(&conn) else {
return false;
};
let Some((window, state, mut menu)) = play
let Some((window, state, mut menu, pos)) = play
.crafting
.table
.take_if(|(window, _, _)| *window == p.container)
.take_if(|(window, ..)| *window == p.container)
else {
return false;
};
// A table that is no longer valid takes no clicks (handleContainerClick).
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
});
}
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
true
}
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
/// guest is within reach of it.
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
let name = &self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name;
&**name == "minecraft:crafting_table" && g.within_block_range(pos, 4.0)
}
/// `ServerPlayer.tick`'s `stillValid`: a guest's crafting table closes
/// once it is gone or out of reach, giving its grid back.
pub(super) fn lan_tables_tick(&mut self, host: &mut LanHost) {
let invalid: Vec<(u32, i32)> = host
.play
.iter()
.filter_map(|(c, p)| p.crafting.table.as_ref().map(|t| (*c, t.0, t.3)))
.filter(|&(c, _, pos)| {
host.hub
.guests
.iter()
.find(|g| g.conn == c)
.is_some_and(|g| host.hub.here(g) && !self.lan_table_valid(g, pos))
})
.map(|(c, window, _)| (c, window))
.collect();
for (conn, window) in invalid {
self.lan_crafting_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
self.guest_carried_back(host, gi);
}
}
}
/// The whole crafting table menu for the client (`sendAllDataToRemote`).
fn lan_send_table_content(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let Some((window, state, menu)) = host
let Some((window, state, menu, _)) = host
.play
.get_mut(&conn)
.and_then(|p| p.crafting.table.as_mut())
@@ -499,9 +547,9 @@ impl Game<'_> {
} else {
match crafting
.table
.take_if(|(w, _, _)| window.is_none_or(|window| *w == window))
.take_if(|(w, ..)| window.is_none_or(|window| *w == window))
{
Some((_, _, menu)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
Some((_, _, menu, _)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
None => return false,
}
};
@@ -527,7 +575,7 @@ impl Game<'_> {
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
g.craft_result = None;
if let Some(play) = play {
if let Some((_, _, menu)) = play.crafting.table {
if let Some((_, _, menu, _)) = play.crafting.table {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
+10 -2
View File
@@ -404,11 +404,19 @@ impl Game<'_> {
Value::Item(hub.stack(&Some(r.item.clone()))),
));
}
let boosted = match r.guest {
Some(conn) => hub
.guests
.iter()
.find(|g| g.conn == conn)
.map(|g| g.entity_id),
None => host_entity,
};
if r.attached
&& let Some(host) = host_entity
&& let Some(player) = boosted
{
e.meta
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(host + 1)));
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(player + 1)));
}
if r.angled {
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
+2 -26
View File
@@ -588,7 +588,7 @@ impl Game<'_> {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
if !(block && within_reach(g, gs.pos)) {
if !(block && g.within_block_range(gs.pos, 4.0)) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -631,16 +631,7 @@ impl Game<'_> {
Some(BlockEntity::Lectern(l)) if l.book.is_some()
);
// Container.stillValidBlockEntity: the interaction range and its buffer.
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
let pos = [key.0, key.1, key.2];
let near = (0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach;
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
if has_book && near {
self.lan_send_lectern(host, conn, false);
} else {
@@ -660,18 +651,3 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
.unwrap_or_default()
}
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
/// block's box.
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach
}
+6 -3
View File
@@ -218,7 +218,7 @@ impl Game<'_> {
oy: f32,
draws: &[ScreenDraw],
background: bool,
) -> Option<Vec<(String, u32)>> {
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
let atlas = &self.gui.atlas;
let mut tooltip = None;
let mut sheet: Option<String> = None;
@@ -261,7 +261,10 @@ impl Game<'_> {
..
} => self.draw_banner_result(batch, ox + *x0 as f32, oy + *y0 as f32, base, layers),
ScreenDraw::Tooltip { key } => {
tooltip = Some(vec![(self.translate(key, key), u32::MAX)]);
tooltip = Some(vec![(
horizon_client::text::Text::literal(self.translate(key, key)),
u32::MAX,
)]);
}
ScreenDraw::SpriteOf { id } => sheet = Some(id.clone()),
ScreenDraw::Fill {
@@ -314,7 +317,7 @@ impl Game<'_> {
ox: f32,
oy: f32,
local: (f32, f32),
) -> Option<Vec<(String, u32)>> {
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
let s = self.station.as_ref()?;
let l = &s.loom;
let dye = s.inputs[1]
+31 -19
View File
@@ -889,13 +889,31 @@ impl Game<'_> {
let key_is_cart = self
.container
.is_some_and(|k| k.1 == super::minecarts::CART_KEY_Y);
let (texture, height, title) = match screen {
Screen::Hopper if key_is_cart => {
("gui/container/hopper", 133.0, "Minecart with Hopper")
}
Screen::Hopper => ("gui/container/hopper", 133.0, "Item Hopper"),
Screen::Dispenser => ("gui/container/dispenser", 166.0, "Dispenser"),
Screen::Dropper => ("gui/container/dispenser", 166.0, "Dropper"),
let (texture, height, key, fallback) = match screen {
Screen::Hopper if key_is_cart => (
"gui/container/hopper",
133.0,
"entity.minecraft.hopper_minecart",
"Minecart with Hopper",
),
Screen::Hopper => (
"gui/container/hopper",
133.0,
"container.hopper",
"Item Hopper",
),
Screen::Dispenser => (
"gui/container/dispenser",
166.0,
"container.dispenser",
"Dispenser",
),
Screen::Dropper => (
"gui/container/dispenser",
166.0,
"container.dropper",
"Dropper",
),
};
if let Some(mut r) = atlas.sprites.get(texture).copied() {
let dv = (r.v1 - r.v0) / r.h as f32;
@@ -905,23 +923,17 @@ 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
let title = self
.remote_menu_title()
.unwrap_or_else(|| self.container_title(title.to_owned()));
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
let title_x = if screen == Screen::Hopper {
8.0
} else {
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0
};
batch.text(atlas, title, ox + title_x, oy + 6.0, grey, false);
batch.text(
atlas,
"Inventory",
ox + 8.0,
oy + height - 94.0,
grey,
false,
);
batch.text(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
}
}
@@ -256,7 +256,8 @@ impl Game<'_> {
grey,
false,
);
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
self.paint_screen(batch, ox, oy, rest, true);
}
/// `gui.togglable_slot` over an empty, enabled grid slot.
+2 -2
View File
@@ -259,7 +259,7 @@ impl Game<'_> {
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
let Some(item) = item else { continue };
let left = off_hand != mob.extra.main_hand_left;
let left = off_hand != mob.main_arm_left();
let Some(t) =
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
else {
@@ -305,7 +305,7 @@ impl Game<'_> {
.player_model
.as_ref()
.is_some_and(|p| std::ptr::eq(p, mob));
owner.main_arm_left = mob.extra.main_hand_left;
owner.main_arm_left = mob.main_arm_left();
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
owner.using_this_item = true;
owner.use_duration = 72_000;
+389 -1
View File
@@ -20,10 +20,13 @@ use {
horizon_client::{
extras::Corner,
extras_hud,
items::ItemStack,
mod_list::Installed,
options_ui::Page,
text::{self, Text},
ui,
},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
std::time::Instant,
};
@@ -39,6 +42,31 @@ pub(super) struct ModsState {
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
/// `command.run` lines for this window's own world, run on the next tick:
/// run at once, their chat lines would reach the mods while their output
/// is still being shown, and a mod answering its own lines would recurse.
#[cfg(feature = "mods")]
commands: Vec<String>,
/// The server's command tree with the running mods' commands added.
#[cfg(feature = "mods")]
remote_tree: Option<Dispatcher>,
/// A `ui` mod's screen or settings page while it is up.
#[cfg(feature = "mods")]
screen: Option<ModScreen>,
}
/// 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")]
struct ModScreen {
/// The running mod's index in `mods.mods.entries()`.
owner: usize,
/// Opened from Options > Mods: Done and Esc return there.
settings: bool,
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
under: Screen,
/// One scroll per `list` widget, in widget order.
scrolls: Vec<ui::Scroll>,
}
impl ModsState {
/// The chat's command tree: vanilla's, plus the running mods' commands.
@@ -49,6 +77,14 @@ impl ModsState {
}
vanilla::game_dispatcher()
}
/// The server's tree plus the mods' commands, when a mod owns any.
pub(super) fn remote_dispatcher(&self) -> Option<&Dispatcher> {
#[cfg(feature = "mods")]
if let Some(tree) = &self.remote_tree {
return Some(tree);
}
None
}
/// Whether a mod runs: the chat keeps its lines for them.
pub(super) fn running(&self) -> bool {
#[cfg(feature = "mods")]
@@ -158,6 +194,7 @@ impl Game<'_> {
);
}
}
pub(super) fn merge_remote_tree(&mut self) {}
pub(super) fn sync_mods(&mut self) {}
pub(super) fn tick_mods(&mut self) {}
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
@@ -166,6 +203,11 @@ impl Game<'_> {
}
pub(super) fn mods_chat(&mut self, _line: &str) {}
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
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_scroll(&mut self, _delta: f32) {}
}
#[cfg(feature = "mods")]
@@ -187,6 +229,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;
// Entries were replaced wholesale: a screen's owner index is stale.
self.mods.screen = None;
self.list_mods();
self.sync_mod_commands();
self.show_mod_output();
@@ -212,6 +256,7 @@ impl Game<'_> {
description: String::new(),
problem,
dev: false,
has_settings: false,
};
};
// What it may do first: the row cuts long descriptions.
@@ -227,6 +272,7 @@ impl Game<'_> {
description: format!("{may} {}", m.description).trim().to_owned(),
problem,
dev: dev.contains(&e.path),
has_settings: e.has_ui_settings(),
}
})
.collect();
@@ -238,7 +284,20 @@ impl Game<'_> {
.peek()
.is_some()
.then(|| vanilla::game_dispatcher().with_mod_commands(names));
self.merge_remote_tree();
}
/// The server's command tree with the mods' commands, for the chat.
pub(super) fn merge_remote_tree(&mut self) {
let names: Vec<String> = self.mods.mods.commands().map(str::to_owned).collect();
self.mods.remote_tree = self
.remote
.as_ref()
.and_then(|r| r.commands.as_ref())
.filter(|_| !names.is_empty())
.map(|t| t.with_mod_commands(names.iter().map(String::as_str)));
}
/// After the options changed: restart the mods if the list of the ones
/// turned on did.
pub(super) fn sync_mods(&mut self) {
@@ -283,6 +342,11 @@ impl Game<'_> {
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
// Here, at the player's level: shown inside a console call (a control
// socket command set the hook off), they would run as the console.
for line in std::mem::take(&mut self.mods.commands) {
self.run_command(&line);
}
if !self.mods.mods.is_running() {
return;
}
@@ -342,11 +406,36 @@ impl Game<'_> {
}
Output::Feedback { text, error: false } => Text::literal(text),
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
// Vanilla's commands only: a mod running a mod command could loop.
Output::Command { line } => {
let name = line.split(' ').next().unwrap_or_default();
if !vanilla_command(name) {
// Dropped.
} else if self.remote.is_some() {
// The server checks the player's permissions.
self.send_chat(&format!("/{line}"));
} else {
self.mods.commands.push(line);
}
continue;
}
Output::Stopped { name, reason } => {
// Its screen goes with it: the entry's run is gone, so
// `mod_ui_view` finds no widgets and the screen closes.
self.list_mods();
self.sync_mod_commands();
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
}
Output::OpenScreen { owner } => {
self.open_mod_screen(owner, false);
continue;
}
Output::CloseScreen { owner } => {
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
self.close_mod_screen(false);
}
continue;
}
};
// Not through system_message: a mod must not hear its own lines.
let focused = self.screen == Screen::Chat;
@@ -354,6 +443,12 @@ impl Game<'_> {
.chat
.add(&self.gui.atlas, line, None, 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.
let rows = self.settings.mods.installed.iter_mut();
for (row, e) in rows.zip(self.mods.mods.entries()) {
row.has_settings = e.has_ui_settings();
}
}
/// The mods' HUD panels, stacked with the extras in their corners.
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
@@ -376,4 +471,297 @@ impl Game<'_> {
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
}
}
/// 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()?;
if ms.settings {
let widgets = self.mods.mods.settings_widgets(ms.owner);
if widgets.is_empty() {
return None;
}
let name = self
.mods
.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()))
} else {
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
Some((s.title.clone(), s.widgets.clone()))
}
}
/// 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)> {
let mut out = Vec::with_capacity(widgets.len());
let mut y = MOD_UI_TOP;
for w in widgets {
let wh = match w {
UiWidget::Text(_) => 12.0,
UiWidget::Item(_) => 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.
if y + wh > h - 34.0 {
break;
}
out.push((y, wh));
y += wh + 6.0;
}
out
}
/// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens
/// its settings page (`settings`, the mod's `installed` index).
pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) {
if self.title.is_some() {
return;
}
if settings {
self.mods.mods.ui_settings_opened(owner);
if self.mods.mods.settings_widgets(owner).is_empty() {
self.show_mod_output();
return;
}
}
self.mods.screen = Some(ModScreen {
owner,
settings,
under: self.screen,
scrolls: Vec::new(),
});
self.screen = Screen::Mod;
if self.player.is_some() {
self.capture(false);
}
self.show_mod_output();
}
/// Leaves the mod screen: back to Options for a settings page, to what
/// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`.
pub(super) fn close_mod_screen(&mut self, tell: bool) {
let Some(ms) = self.mods.screen.take() else {
return;
};
if ms.settings {
self.open_options_at(self.options_from_title, Page::Mods);
} else {
self.screen = ms.under;
if self.screen == Screen::Playing && self.player.is_some() {
self.capture(true);
}
}
if tell {
self.mods.mods.ui_closed(ms.owner);
self.show_mod_output();
}
}
/// 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() {
self.screen = Screen::Playing;
return;
}
let Some((title, widgets)) = self.mod_ui_view() else {
self.mods.screen = None;
self.screen = Screen::Playing;
return;
};
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
// so `atlas` may not stay borrowed across it.
let mut items: Vec<(String, f32, f32)> = Vec::new();
let atlas = &self.gui.atlas;
let in_world = self.player.is_some();
ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world);
ui::centered_text(
batch,
atlas,
&Text::literal(title),
0.0,
15.0,
batch.width,
u32::MAX,
);
let cx = (batch.width / 2.0).floor();
let x = cx - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(batch.height, &widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
match w {
UiWidget::Text(t) => {
let color = if t.color == 0 {
u32::MAX
} else {
t.color | 0xFF00_0000
};
ui::centered_text(
batch,
atlas,
&Text::literal(t.text.clone()),
x,
wy + 1.0,
MOD_UI_COLUMN,
color,
);
}
UiWidget::Item(i) => {
if self.res.items().get(&i.item).is_some() {
items.push((i.item.clone(), x + 2.0, wy + 2.0));
} else {
batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true);
}
let name = if i.name.is_empty() {
i.item.rsplit(':').next().unwrap_or(&i.item).to_string()
} else {
i.name.clone()
};
batch.text_styled(
atlas,
&Text::literal(name),
x + 22.0,
wy + 6.0,
u32::MAX,
true,
);
}
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]
};
list_i += 1;
let rows_top = wy + 4.0;
let rows_bottom = wy + wh - 4.0;
batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000);
for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] {
batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050);
}
for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] {
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
}
let content = l.rows.len() as f32 * 12.0;
let first = (scroll.amount / 12.0) as usize;
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
.min(l.rows.len());
for (vi, ri) in (first..last).enumerate() {
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
}
if l.selected == Some(ri as u32) {
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
}
batch.text_styled(
atlas,
&Text::literal(l.rows[ri].clone()),
x + 6.0,
ry + 2.0,
u32::MAX,
true,
);
}
ui::scrollbar(
batch,
atlas,
x + MOD_UI_COLUMN - 5.0,
rows_top,
rows_bottom,
content,
scroll.amount,
);
}
}
}
let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0);
ui::button(
batch,
atlas,
cx - 100.0,
batch.height - 27.0,
200.0,
20.0,
&Text::literal("Done"),
true,
hover,
);
for (id, ix, iy) in items {
let stack = ItemStack::new(id.as_str(), 1);
self.draw_item(batch, &stack, ix, iy);
}
}
/// 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) {
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) {
self.audio.play_button_click();
self.close_mod_screen(true);
return;
}
let Some((_, widgets)) = self.mod_ui_view() else {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &widgets);
let mut list_i = 0usize;
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
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();
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;
}
}
}
/// 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 {
return;
};
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
let layout = self.mod_ui_layout(h, &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;
ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content);
return;
}
list_i += 1;
}
}
}
#[cfg(feature = "mods")]
const MOD_UI_TOP: f32 = 40.0;
#[cfg(feature = "mods")]
const MOD_UI_COLUMN: f32 = 200.0;
+2 -1
View File
@@ -470,7 +470,8 @@ impl Game<'_> {
None => String::new(),
};
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
if let Some(m) = self.mobs.iter().find(|m| m.id == open.mob) {
self.draw_mob_preview(
batch,
+8 -4
View File
@@ -151,10 +151,13 @@ impl Game<'_> {
.clone()
.unwrap_or_else(|| Text::literal(&name))
};
// StringDecomposer.iterateFormatted: `§` codes a server leaves
// inside the team prefix or the name itself apply at draw time.
let shown = match team {
Some(t) => t.formatted_name(member),
None => member,
};
}
.resolve_legacy();
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
// below-name distance (10), under the name.
@@ -162,7 +165,7 @@ impl Game<'_> {
if distance_sqr < 10.0 * 10.0
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score, 0.0));
lines.push((score.resolve_legacy(), 0.0));
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
lines.push((shown, lift));
@@ -213,14 +216,15 @@ impl Game<'_> {
let shown = match team {
Some(t) => t.formatted_name(name.clone()),
None => name.clone(),
};
}
.resolve_legacy();
let mut lines = Vec::new();
let mut lift = 0.0;
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
if distance_sqr < 10.0 * 10.0
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score, 0.0));
lines.push((score.resolve_legacy(), 0.0));
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
lines.push((shown, lift));
@@ -237,6 +237,7 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
}
@@ -246,6 +247,7 @@ impl Game<'_> {
pub(super) fn load_language(&mut self, code: &str) {
let root = self.options.resources.clone();
lang::set(lang::Language::load(&[&root], code));
self.refresh_remote_language();
self.ui.chat.rescale(&self.gui.atlas);
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
+1
View File
@@ -203,6 +203,7 @@ impl Game<'_> {
horizon_client::mobs::villager_hats::VillagerHats::load(root);
horizon_client::ui::load_metadata(root);
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
self.refresh_remote_language();
self.audio.reload(root);
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
+4 -2
View File
@@ -921,8 +921,10 @@ impl Game<'_> {
} else if self.book.is_some() {
self.book_input(key, text);
} else if self.screen == Screen::Creative && self.creative_screen.tab().search {
if key != Some(KeyCode::Enter) {
self.creative_search_edit(text, backspace);
if backspace {
self.creative_search_backspace(false);
} else if key != Some(KeyCode::Enter) {
self.creative_search_edit(text);
}
} else if self.recipe_search_focused() {
if key != Some(KeyCode::Enter) {
+10 -2
View File
@@ -147,8 +147,16 @@ impl Game<'_> {
}
}
Species::Strider if !baby => self.spawn_strider_jockey(id, at),
// ZombieHorse.finalizeSpawn: a zombie with an iron spear.
Species::ZombieHorse if natural => {
// ZombieHorse.finalizeSpawn: a zombie with an iron spear. The rider
// comes from EntityType.create, which makes no zombie in peaceful
// (canSpawn), so a peaceful zombie horse spawns without one.
Species::ZombieHorse
if natural
&& (self.ui.difficulty != 0
|| horizon_client::mobs::spawn_rules::allowed_in_peaceful(
Species::Zombie,
)) =>
{
if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) {
if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) {
z.extra.main_hand = Some("minecraft:iron_spear".into());
+3 -4
View File
@@ -76,15 +76,14 @@ impl Game<'_> {
let pos = ChunkPos::new(chunk.x, chunk.z);
let time = self.world.game_time;
let mut extras: ChunkExtras = chunk.saved.as_deref().cloned().unwrap_or_default();
// A chunk the scheduler hasn't registered yet (one loaded and
// unloaded again before the next tick) still holds its saved ticks:
// `register_chunk_ticks` empties that list once it takes them.
if let Some(t) = self.block_ticks.blocks.chunk(pos) {
extras.block_ticks = t.pack(time);
} else {
extras.block_ticks.clear();
}
if let Some(t) = self.block_ticks.fluids.chunk(pos) {
extras.fluid_ticks = t.pack(time);
} else {
extras.fluid_ticks.clear();
}
extras.last_update = time;
// TheEndGatewayBlockEntity saves its Age (the server copy's).
@@ -27,6 +27,11 @@ impl Game<'_> {
if ready.is_empty() {
return Ok(());
}
// The ticks the marks schedule go to the chunks' containers, which a
// chunk that arrived since the last tick doesn't have yet
// (`LevelChunk.registerTickContainerInLevel` comes before
// `postProcessGeneration`).
self.sync_tick_chunks();
for key in ready {
let Some(chunk) = self.world.chunks.get_mut(&key) else {
continue;
+1 -1
View File
@@ -755,7 +755,7 @@ impl Game<'_> {
image_width,
&self.potions.effects.list,
self.clock.rate(),
&|key| self.pack.names.get(key).map(str::to_owned),
&|key| self.pack.names.display_get(key),
&|text| self.gui.atlas.font.width(text).ceil() as i32,
);
if let Some(lines) = inventory_effects::paint(
+17 -6
View File
@@ -502,7 +502,7 @@ impl Game<'_> {
horizon_client::item_tooltip::lines(
stack,
self.res.items().get(&stack.item),
&|k| self.pack.names.get(k).map(str::to_owned),
&|k| self.pack.names.display_get(k),
)
})
{
@@ -1163,8 +1163,12 @@ impl Game<'_> {
}
/// Paints recipe book calls; returns the first tooltip set (vanilla keeps
/// the first one a frame asks for).
fn paint_book(&mut self, batch: &mut HudBatch, calls: &[Drawn]) -> Option<Vec<(String, u32)>> {
let mut tooltip: Option<Vec<(String, u32)>> = None;
fn paint_book(
&mut self,
batch: &mut HudBatch,
calls: &[Drawn],
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
for d in calls {
let start = batch.vertices.len();
let atlas = &self.gui.atlas;
@@ -1259,7 +1263,14 @@ impl Game<'_> {
tooltip = Some(
lines
.iter()
.map(|l| (l.iter().map(|r| r.text.as_str()).collect(), u32::MAX))
.map(|l| {
(
horizon_client::text::Text::literal(
l.iter().map(|r| r.text.as_str()).collect::<String>(),
),
u32::MAX,
)
})
.collect(),
);
}
@@ -1269,10 +1280,10 @@ impl Game<'_> {
let mut lines = self.stack_tooltip(stack);
if *more {
lines.push((
self.translate(
horizon_client::text::Text::literal(self.translate(
"gui.recipebook.moreRecipes",
"Right Click for More",
),
)),
u32::MAX,
));
}
+80 -234
View File
@@ -223,30 +223,6 @@ fn face_direction(face: [i32; 3]) -> u8 {
}
}
/// A `minecraft:profile` component as the item data JSON the model reads.
fn profile_tag(p: &horizon_protocol::item::Profile) -> Tag {
let properties = p
.properties
.iter()
.map(|(name, value, signature)| {
let mut entries: Vec<(&'static str, Tag)> = vec![
("name", Tag::String(name.as_str().into())),
("value", Tag::String(value.as_str().into())),
];
if let Some(signature) = signature {
entries.push(("signature", Tag::String(signature.as_str().into())));
}
Tag::compound(entries)
})
.collect::<Vec<_>>();
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
if let Some(name) = &p.name {
entries.push(("name", Tag::String(name.as_str().into())));
}
entries.push(("properties", Tag::List(properties)));
Tag::compound(entries)
}
/// A protocol number format as the scoreboard's.
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
@@ -610,164 +586,32 @@ impl Game<'_> {
.and_then(|i| i.id(name))
.unwrap_or(0)
}
/// A stack from the server, decoded as the saved and command item NBT
/// are, so tooltips and models see every field.
pub(super) fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
let s = s.as_ref()?;
let mut out = ItemStack {
item: self.item_name(s.item),
count: s.count.max(0) as u32,
damage: 0,
extra: None,
};
// A vanilla `components` compound, then the same decoder the saved
// and command item NBT use, so tooltips and models see every field.
let components = self.protocol_components(s);
let (damage, data) = horizon_client::item_data::decode_components(components.as_ref());
out.damage = damage;
if let Some(data) = data {
*out.data_mut() = *data;
}
out.tidy();
Some(out)
}
/// The item's patched components as vanilla's `components` compound.
fn protocol_components(&self, s: &Stack) -> Option<Tag> {
use horizon_protocol::item::{Component, component};
let r = self.remote.as_ref()?;
let name = |id: i32| r.builtin.get("minecraft:data_component_type")?.name(id);
let mut entries: Vec<(&str, Tag)> = Vec::new();
for (kind, c) in &s.added {
let Some(key) = name(*kind) else {
continue;
};
let value = match c {
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
Component::Trim { material, pattern } => {
let (Some(material), Some(pattern)) = (
r.registries.name("minecraft:trim_material", *material),
r.registries.name("minecraft:trim_pattern", *pattern),
) else {
continue;
};
Tag::compound([
("material", Tag::String(material.into())),
("pattern", Tag::String(pattern.into())),
])
}
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
}
Component::Int(v) => Tag::Int(*v),
Component::Unit => Tag::Compound(Default::default()),
Component::Bool(v) => Tag::Byte(i8::from(*v)),
Component::Text(t) | Component::Nbt(t) => t.clone(),
Component::Ident(v) => Tag::String(v.as_str().into()),
Component::Lore(lines) => Tag::List(lines.clone()),
Component::WritableBook(pages) => Tag::compound([(
"pages",
Tag::List(
pages
.iter()
.map(|p| Tag::String(p.as_str().into()))
.collect(),
),
)]),
Component::WrittenBook(b) => Tag::compound([
("title", Tag::String(b.title.as_str().into())),
("author", Tag::String(b.author.as_str().into())),
("generation", Tag::Int(b.generation)),
("pages", Tag::List(b.pages.clone())),
("resolved", Tag::Byte(i8::from(b.resolved))),
]),
Component::Enchantments(list) => {
// Enchantments are a synced (data-driven) registry: the
// ids index the server's RegistryData, not the builtin
// report, so a datapack enchantment names correctly.
let levels = list.iter().filter_map(|(id, level)| {
let name = r.registries.name("minecraft:enchantment", *id)?;
Some((name, Tag::Int(*level)))
});
Tag::compound(levels)
}
Component::AttackRange(v) => Tag::compound([
("min_reach", Tag::Float(v[0].to_bits())),
("max_reach", Tag::Float(v[1].to_bits())),
("min_attack_momentum", Tag::Float(v[2].to_bits())),
("max_attack_momentum", Tag::Float(v[3].to_bits())),
("hitbox_margin", Tag::Float(v[4].to_bits())),
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// ItemStackTemplate.CODEC: id, count and components.
Component::Stacks(stacks) => Tag::List(
stacks
.iter()
.map(|t| {
let mut entries = vec![
("id", Tag::String(self.item_name(t.item).as_ref().into())),
("count", Tag::Int(t.count)),
];
if let Some(c) = self.protocol_components(t) {
entries.push(("components", c));
}
Tag::compound(entries)
})
.collect(),
),
Component::CustomModelData {
floats,
flags,
strings,
colors,
} => Tag::compound([
(
"floats",
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
),
(
"flags",
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
),
(
"strings",
Tag::List(
strings
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
(
"colors",
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
),
]),
Component::TooltipDisplay { hide, hidden } => {
let hidden = hidden
.iter()
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
Tag::compound([
("hide_tooltip", Tag::Byte(i8::from(*hide))),
("hidden_components", Tag::List(hidden.collect())),
])
}
};
entries.push((key, value));
}
Some(Tag::compound(entries))
Some(horizon_client::item_sync::from_network(
s.as_ref()?,
&r.builtin,
&r.registries,
))
}
/// A stack to the server (`SetCreativeModeSlot`): the same component
/// patch a Horizon server sends its clients, with the registries' ids, so
/// the server's echo doesn't hand back a bare item.
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
let s = s.as_ref()?;
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
if s.damage > 0 {
stack.added.push((
horizon_protocol::item::component::DAMAGE,
horizon_protocol::item::Component::Int(s.damage as i32),
));
}
Some(stack)
let r = self.remote.as_ref()?;
horizon_client::lan::guest::stack_to_network(
|name| self.item_id(name),
// Built-in first, as a hub's `SyncData::id` does: potions are one.
|registry, name| {
r.builtin
.get(registry)
.and_then(|ids| ids.id(name))
.or_else(|| r.registries.id(registry, name))
},
s,
)
}
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage).
@@ -1576,10 +1420,10 @@ impl Game<'_> {
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// The vehicle the player rode is gone (Entity.removeVehicle).
if self.riding == Some(id as u32) {
self.riding = None;
}
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
@@ -2046,15 +1890,6 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
for (index, _, value) in &p.values {
if let (15, Value::Byte(flags)) = (index, value) {
m.no_ai = flags & 1 != 0;
}
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
@@ -2069,25 +1904,12 @@ impl Game<'_> {
}
}
}
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS):
// another player starts using the item in a hand (eating, a
// bow drawn, a shield up), or stops.
// 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);
if other && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
if let (8, Value::Byte(flags)) = (index, value) {
m.extra.using_off_hand = flags & 2 != 0;
if flags & 1 == 0 {
m.extra.using_item = 0;
} else if m.extra.using_item == 0 {
m.extra.using_item = 1;
}
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
// player lies down in their bed (drawn lying in it), or gets up.
if other {
@@ -2337,12 +2159,10 @@ impl Game<'_> {
}
}
C::PlayerCombatKill(p) => {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
let message = plain(&p.message, &lang);
// The component resolves in the client's current language, as
// the chat line does, not in the language the session joined
// with (`Remote.lang` is the vanilla server-language table).
let message = remote_text(&p.message).plain();
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
@@ -2545,6 +2365,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.commands = Some(tree);
}
self.merge_remote_tree();
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
@@ -3239,6 +3060,7 @@ impl Game<'_> {
self.tick_block_entity_anims();
self.tick_hand();
self.tick_particles();
self.tick_client_ambient();
self.tick_client_effects();
// Player.tick's sleep counter (the darkening and the wake-up fade).
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
@@ -3572,25 +3394,28 @@ impl Game<'_> {
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
*steps -= 1;
}
// LivingEntity.updatingUsingItem for another player: the ticks the
// item has been used before this one.
if player && m.extra.using_item > 0 {
let item = if m.extra.using_off_hand {
m.extra.off_hand.clone()
} else {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
m.extra.using_item as u32 - 1,
at,
eye,
m.head_pitch,
m.yaw,
));
// 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).
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
m.extra.off_hand.clone()
} else {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
m.extra.using_item as u32 - 1,
at,
eye,
m.head_pitch,
m.yaw,
));
}
}
m.extra.using_item += 1;
}
@@ -4061,8 +3886,14 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
} else if let Some(stack) = held.as_ref() {
self.predict_item_on_block(stack, &hit);
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
// Item.useOn succeeded here (InteractionResult.SUCCESS):
// this hand swings and the click ends, so the other hand
// (a shield, a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
}
if held_block {
let placed = if reacts {
@@ -4092,7 +3923,8 @@ impl Game<'_> {
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
/// Whether the item's use succeeded, which ends the click.
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
@@ -4111,11 +3943,15 @@ impl Game<'_> {
self.audio
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
}
waxes
} else if let Some((_, sound, _, _, particle)) =
self.block_transform(stack, hit.pos, hit.face)
{
self.transform_particle(&particle, hit.pos);
self.audio.play(&sound, Some(at), 1.0, 1.0);
true
} else {
false
}
}
@@ -4976,6 +4812,7 @@ impl Game<'_> {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: std::collections::HashMap::new(),
}))
}
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
@@ -5060,6 +4897,15 @@ impl Game<'_> {
pub(super) fn remote_menu_title(&self) -> Option<String> {
self.remote.as_ref()?.menu_title.clone()
}
/// A language change reaches the server-sent components too: their titles
/// are resolved when the packet arrives, with the session's language.
pub(super) fn refresh_remote_language(&mut self) {
if let Some(lang) = horizon_client::lang::current()
&& let Some(r) = self.remote.as_mut()
{
r.lang = Arc::new(lang.map().clone());
}
}
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
let r = self.remote.as_ref()?;
@@ -20,9 +20,28 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
// off hand 2. The flag starts and stops the use; the client ticks
// the count itself (a bow's pull follows it).
(8, Value::Byte(flags)) => {
m.extra.using_off_hand = flags & 2 != 0;
if flags & 1 == 0 {
m.extra.using_item = 0;
} else if m.extra.using_item == 0 {
m.extra.using_item = 1;
}
}
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
// animation follow it.
(9, Value::Float(health)) => m.health = *health,
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
// Mob.isAggressive draws the illagers' and skeletons' attack
// poses and the vindicator's axe.
(15, Value::Byte(flags)) => {
m.no_ai = flags & 1 != 0;
m.extra.left_handed = flags & 2 != 0;
m.ai.aggressive = flags & 4 != 0;
}
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
_ => {}
}
@@ -45,6 +64,13 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
m.extra.variant2 = (*spell).max(0) as u8;
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
// Zombie's three): what VillagerProfessionLayer draws.
(Species::Villager, 19, Value::Ids(data))
@@ -178,6 +204,19 @@ mod tests {
assert_eq!(m.fire_ticks, 0);
}
#[test]
fn mob_flags_turn_aggression_on_and_off() {
let Some(mut m) = mob("minecraft:vindicator") else {
return;
};
apply(&mut m, &[(15, s::BYTE, Value::Byte(4 | 2))]);
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
assert!(horizon_client::mobs::illager::shows_item(&m));
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
assert!(!m.ai.aggressive && !m.extra.left_handed);
assert!(!horizon_client::mobs::illager::shows_item(&m));
}
#[test]
fn a_villager_takes_its_type_profession_and_level() {
let Some(mut m) = mob("minecraft:villager") else {
@@ -218,4 +257,42 @@ mod tests {
);
assert_eq!(name(&z), ("desert", "farmer", 3));
}
#[test]
fn the_mob_flags_drive_the_aggressive_poses() {
let Some(mut m) = mob("minecraft:skeleton") else {
return;
};
apply(&mut m, &[(15, s::BYTE, Value::Byte(2 | 4))]);
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
assert!(!m.ai.aggressive && !m.extra.left_handed);
}
#[test]
fn the_living_flags_start_and_stop_a_mob_using_an_item() {
let Some(mut m) = mob("minecraft:skeleton") else {
return;
};
apply(&mut m, &[(8, s::BYTE, Value::Byte(1))]);
assert_eq!(m.extra.using_item, 1);
assert!(!m.extra.using_off_hand);
// Using in the off hand.
apply(&mut m, &[(8, s::BYTE, Value::Byte(3))]);
assert!(m.extra.using_off_hand);
apply(&mut m, &[(8, s::BYTE, Value::Byte(0))]);
assert_eq!(m.extra.using_item, 0);
}
#[test]
fn a_pillager_charges_and_a_spellcaster_casts() {
let Some(mut p) = mob("minecraft:pillager") else {
return;
};
apply(&mut p, &[(17, s::BOOLEAN, Value::Bool(true))]);
assert!(p.extra.flag);
let mut e = mob("minecraft:evoker").unwrap();
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
assert_eq!(e.extra.variant2, 2);
}
}
@@ -2,7 +2,7 @@
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game};
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::entity_data::Value;
@@ -125,6 +125,37 @@ impl Game<'_> {
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
self.remote_hook_data(id, values);
if self.rockets.iter().any(|r| r.id == id) {
for (index, _, value) in values {
match (index, value) {
// FireworkRocketEntity.DATA_ID_FIREWORKS_ITEM: its
// explosions for `createFireworks`.
(8, Value::Item(stack)) => {
if let Some(stack) = self.to_stack(stack)
&& let Some(r) = self.rockets.iter_mut().find(|r| r.id == id)
{
let (_, explosions) = fireworks_of(&stack);
r.explosions = explosions;
r.item = stack;
}
}
// DATA_ATTACHED_TO_TARGET (OptionalInt: the id plus 1).
(9, Value::Id(v)) => {
let v = (*v > 0).then(|| (*v - 1) as u32);
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
r.attached_to = v;
}
}
// DATA_SHOT_AT_ANGLE.
(10, Value::Bool(a)) => {
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
r.angled = *a;
}
}
_ => {}
}
}
}
if self.potions.has_thrown(id) {
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
for (index, _, value) in values {
@@ -213,9 +244,30 @@ impl Game<'_> {
let id = t.id;
t.remote_step(|at| step(id, at));
}
// FireworkRocketEntity.tick: a rocket attached to the gliding local
// player boosts it client-side too (attachedToEntity.isFallFlying).
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
for r in &mut self.rockets {
let id = r.id;
r.remote_step(|at| step(id, at));
if me.is_some()
&& r.attached_to == me
&& let Some(p) = self.player.as_mut()
&& p.fall_flying
{
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
r.position = p.position;
r.velocity = p.velocity;
}
}
for c in &mut self.crystals {
c.time += 1;
@@ -332,6 +332,7 @@ impl Game<'_> {
return;
}
self.load_scoreboard();
host.hub.teams.clone_from(&self.score.board.teams);
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
if g.scoreboard != view {
+24 -6
View File
@@ -2,6 +2,7 @@
use super::{Game, Screen};
use horizon_client::{
Result,
command::args::GameMode,
hud::{HudBatch, rgba},
text::{self, Text},
ui,
@@ -17,7 +18,8 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends => true,
| Self::Friends
| Self::Mod => true,
Self::Playing
| Self::GameModeSwitcher
| Self::Chat
@@ -81,9 +83,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
(x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"),
]
}
fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
/// DeathScreen.init: a hardcore player can only spectate.
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
let respawn = if hardcore {
"deathScreen.spectate"
} else {
"deathScreen.respawn"
};
[
(w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0),
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
(
w / 2.0 - 100.0,
h / 4.0 + 96.0,
@@ -223,7 +231,12 @@ impl Game<'_> {
),
);
}
let title = Text::translate("deathScreen.title", &[]);
let title = if self.level.hardcore {
"deathScreen.title.hardcore"
} else {
"deathScreen.title"
};
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
ui::text_scaled(
batch,
@@ -258,7 +271,7 @@ impl Game<'_> {
.ui
.died_at
.is_none_or(|t| t.elapsed().as_millis() >= 1000);
for (x, y, key, _) in death_buttons(w, h) {
for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) {
let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0);
ui::button(
batch,
@@ -286,7 +299,7 @@ impl Game<'_> {
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
let hit = death_buttons(b.width, b.height)
let hit = death_buttons(b.width, b.height, self.level.hardcore)
.into_iter()
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
.map(|d| d.3);
@@ -330,6 +343,11 @@ impl Game<'_> {
Some(1) => self.close_world()?,
_ => {}
}
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
self.set_mode(GameMode::Spectator);
}
if hit.is_some() {
self.audio.play_button_click();
}
+7 -6
View File
@@ -78,7 +78,7 @@ impl Game<'_> {
let Some(g) = host.hub.guest(conn) else {
return;
};
let near = super::lan_stations::within_reach(g, p.pos);
let near = g.within_block_range(p.pos, 4.0);
let Some(play) = host.play.get_mut(&conn) else {
return;
};
@@ -248,13 +248,14 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, rgba(16, 16, 16, 160));
let title = if sign.hanging {
"Edit Hanging Sign Message"
let (key, fallback) = if sign.hanging {
("hanging_sign.edit", "Edit Hanging Sign Message")
} else {
"Edit Sign Message"
("sign.edit", "Edit Sign Message")
};
let tw = HudBatch::text_width(atlas, title);
batch.text(atlas, title, (w - tw) / 2.0, 40.0, u32::MAX, true);
let title = self.translate(key, fallback);
let tw = HudBatch::text_width(atlas, &title);
batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true);
let name = self
.res
.facts()
+18 -1
View File
@@ -373,12 +373,29 @@ impl GameLevel<'_> {
let mut neighbour_updates = 0;
if side_effects {
match kind {
PreRemove::Contents { slots, .. } => {
PreRemove::Contents { slots, furnace } => {
let items = entity.as_ref().map_or(&[][..], BlockEntity::slots);
for slot in 0..slots {
let stack = items.get(slot).cloned().flatten();
self.drop_item_stack(pos, stack);
}
// AbstractFurnaceBlockEntity.preRemoveSideEffects: then the
// experience of what it made, at the block's centre.
if furnace && let Some(BlockEntity::Furnace(f)) = &entity {
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
for (count, experience) in f.recipe_experience(self.recipes) {
let xp = horizon_client::containers::create_experience(
count,
experience,
&mut self.world.random,
);
self.award_orbs(at, xp);
}
}
}
PreRemove::Campfire => {
for _ in 0..4 {
@@ -383,6 +383,8 @@ impl Loading {
self.menu.win = Some((screen, Instant::now()));
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+105 -63
View File
@@ -277,15 +277,10 @@ fn argb(c: i32) -> u32 {
}
impl Game<'_> {
fn data(&self) -> &stations::StationData {
self.res.stations()
}
/// `Component.translatable`: the selected language (which `lang` layers
/// over `en_us`), the caller's English text last.
pub(super) fn translate(&self, key: &str, fallback: &str) -> String {
self.data()
.enchantments
.translate(key)
.unwrap_or(fallback)
.to_owned()
horizon_client::lang::get_or(key, fallback)
}
fn brewing_open(&self) -> bool {
matches!(self.container_kind(), Some(BlockEntity::Brewing(_)))
@@ -421,7 +416,8 @@ impl Game<'_> {
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
match &s.inputs[0] {
Some(input)
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
if input.custom_name().is_none()
&& s.name == self.pack.names.display_item(&input.item) =>
{
String::new()
}
@@ -462,7 +458,7 @@ impl Game<'_> {
let name = self.anvil_item_name(&s);
let hover = s.inputs[0]
.as_ref()
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
.unwrap_or_default();
s.anvil = stations::anvil_result(
&data.enchantments,
@@ -895,7 +891,7 @@ impl Game<'_> {
s.anvil_last = s.inputs[0].clone();
s.name = s.inputs[0]
.as_ref()
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
.unwrap_or_default();
}
self.station_changed();
@@ -1096,7 +1092,7 @@ impl Game<'_> {
}
let (ox, oy) = self.inventory_origin(batch);
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
let mut tooltip: Option<Vec<(String, u32)>> = None;
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
{
let atlas = &self.gui.atlas;
batch.fill(
@@ -1345,16 +1341,19 @@ impl Game<'_> {
let clue = self
.translate("container.enchant.clue", "%s . . . ?")
.replace("%s", &name);
let mut lines = vec![(clue, u32::MAX)];
let mut lines =
vec![(horizon_client::text::Text::literal(clue), u32::MAX)];
if !self.creative {
lines.push((String::new(), u32::MAX));
lines.push((horizon_client::text::Text::literal(""), u32::MAX));
if level < cost {
lines.push((
self.translate(
"container.enchant.level.requirement",
"Level Requirement: %s",
)
.replace("%s", &cost.to_string()),
horizon_client::text::Text::literal(
self.translate(
"container.enchant.level.requirement",
"Level Requirement: %s",
)
.replace("%s", &cost.to_string()),
),
rgba(255, 85, 85, 255),
));
} else {
@@ -1372,7 +1371,7 @@ impl Game<'_> {
.replace("%s", &n.to_string())
};
lines.push((
lapis,
horizon_client::text::Text::literal(lapis),
if gold >= n as i32 {
rgba(170, 170, 170, 255)
} else {
@@ -1391,7 +1390,10 @@ impl Game<'_> {
)
.replace("%s", &n.to_string())
};
lines.push((levels, rgba(170, 170, 170, 255)));
lines.push((
horizon_client::text::Text::literal(levels),
rgba(170, 170, 170, 255),
));
}
}
tooltip = Some(lines);
@@ -1500,10 +1502,10 @@ impl Game<'_> {
);
if inside(local, 65.0, 46.0, 28.0, 21.0) {
tooltip = Some(vec![(
self.translate(
horizon_client::text::Text::literal(self.translate(
"container.upgrade.error_tooltip",
"Item can't be upgraded this way",
),
)),
u32::MAX,
)]);
}
@@ -1630,9 +1632,12 @@ impl Game<'_> {
// ---------------------------------------------------------------- tooltips
/// `ItemStack.getTooltipLines` subset: name in rarity colour, potion
/// effects, enchantments (gray, curses red), stored enchantments, trims.
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
/// `ItemStack.getTooltipLines` subset: name in rarity colour, lore,
/// potion effects, enchantments (gray, curses red), stored enchantments, trims.
pub(super) fn stack_tooltip(
&self,
stack: &ItemStack,
) -> Vec<(horizon_client::text::Text, u32)> {
let e: &Enchantments = &self.res.stations().enchantments;
let props = e.props(&stack.item);
let rarity = horizon_client::item_tooltip::rarity(
@@ -1648,39 +1653,43 @@ impl Game<'_> {
let name = if let Some(n) = stack.custom_name() {
n.to_owned()
} else if stack.data().is_some_and(|d| d.ominous_banner) {
e.translate("block.minecraft.ominous_banner")
.map_or_else(|| "Ominous Banner".to_owned(), str::to_owned)
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|| stack.item.ends_with("potion")
|| &*stack.item == "minecraft:tipped_arrow"
{
let key = brewing::potion_name_key(&stack.item, stack.potion());
e.translate(&key)
.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned)
self.pack
.names
.display_get(&key)
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
} else {
self.pack.names.item(&stack.item)
self.pack.names.display_item(&stack.item)
};
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
let mut lines = Vec::new();
if display.name_visible(self.screen == Screen::Creative) {
lines.push((name, color));
lines.push((horizon_client::text::Text::legacy(&name), color));
}
let translate = |k: &str| e.translate(k).map(str::to_owned);
let translate = |k: &str| self.pack.names.display_get(k);
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
lines.push((horizon_client::text::Text::legacy(&s), c));
};
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
.map_or_else(|| self.pack.names.item(&s.item), str::to_owned)
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
};
if display.shows("minecraft:container") {
for line in
for text in
horizon_client::item_tooltip::container_lines(stack, &hover_name, &translate)
{
lines.push((line, u32::MAX));
push(&mut lines, text, u32::MAX);
}
}
// Fireworks / FireworkExplosion.addToTooltip.
for line in horizon_client::item_tooltip::firework_lines(stack, &translate) {
lines.push((line, rgba(170, 170, 170, 255)));
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
push(&mut lines, text, rgba(170, 170, 170, 255));
}
if display.shows("minecraft:potion_contents")
&& (stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow")
@@ -1690,7 +1699,7 @@ impl Game<'_> {
"minecraft:tipped_arrow" => 0.125,
_ => 1.0,
};
for (line, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
e.translate(k).map(str::to_owned)
}) {
let c = match cat {
@@ -1698,18 +1707,19 @@ impl Game<'_> {
brewing::Category::Harmful => rgba(255, 85, 85, 255),
brewing::Category::Neutral => rgba(170, 170, 170, 255),
};
lines.push((line, c));
push(&mut lines, text, c);
}
}
if display.shows("minecraft:trim")
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
{
lines.push((
push(
&mut lines,
e.translate("item.minecraft.smithing_template.upgrade")
.unwrap_or("Upgrade:")
.to_owned(),
rgba(170, 170, 170, 255),
));
);
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
let p = e
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
@@ -1717,38 +1727,51 @@ impl Game<'_> {
let m = e
.translate(&format!("trim_material.minecraft.{}", short(&material)))
.map_or_else(|| short(&material), str::to_owned);
lines.push((format!(" {p}"), rgba(170, 170, 170, 255)));
lines.push((format!(" {m}"), rgba(170, 170, 170, 255)));
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
}
if display.shows("minecraft:stored_enchantments") {
for (text, curse) in e.tooltip(stack.stored_enchantments()) {
lines.push((
push(
&mut lines,
text,
if curse {
rgba(255, 85, 85, 255)
} else {
rgba(170, 170, 170, 255)
},
));
);
}
}
if display.shows("minecraft:enchantments") {
for (text, curse) in e.tooltip(stack.enchantments()) {
lines.push((
push(
&mut lines,
text,
if curse {
rgba(255, 85, 85, 255)
} else {
rgba(170, 170, 170, 255)
},
));
);
}
}
// BannerPatternLayers.addToTooltip: up to six layer names.
if display.shows("minecraft:banner_patterns") {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations().banners.layer_key(layer);
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
push(
&mut lines,
self.translate(&key, &key),
rgba(170, 170, 170, 255),
);
}
}
// ItemLore.addToTooltip: after the enchantments, before the attributes
// (ItemStack.addDetailsToTooltip).
if display.shows("minecraft:lore") {
for text in horizon_client::item_tooltip::lore_texts(stack) {
lines.push((text, u32::MAX));
}
}
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
@@ -1762,15 +1785,15 @@ impl Game<'_> {
match block_entity_item::spawner_entity(stack) {
Some(id) => {
let key = format!("entity.{}", id.replacen(':', ".", 1));
lines.push((self.translate(&key, &id), gray));
push(&mut lines, self.translate(&key, &id), gray);
}
None => {
lines.push((String::new(), u32::MAX));
push(&mut lines, String::new(), u32::MAX);
let desc1 =
self.translate("block.minecraft.spawner.desc1", "Interact with Spawn Egg:");
lines.push((desc1, gray));
push(&mut lines, desc1, gray);
let desc2 = self.translate("block.minecraft.spawner.desc2", "Sets Mob Type");
lines.push((format!(" {desc2}"), rgba(85, 85, 255, 255)));
push(&mut lines, format!(" {desc2}"), rgba(85, 85, 255, 255));
}
}
}
@@ -1798,11 +1821,13 @@ impl Game<'_> {
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
];
lines.extend(horizon_client::item_tooltip::attribute_lines(
for (text, c) in horizon_client::item_tooltip::attribute_lines(
&info.modifiers,
Some(player_base),
&translate,
));
) {
push(&mut lines, text, c);
}
}
if display.shows("minecraft:damage")
&& let Some(info) = self.res.items().get(&stack.item)
@@ -1810,14 +1835,15 @@ impl Game<'_> {
&& stack.damage > 0
&& self.options_advanced_tooltips()
{
lines.push((
push(
&mut lines,
format!(
"Durability: {} / {}",
info.max_damage - stack.damage,
info.max_damage
),
u32::MAX,
));
);
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
@@ -1832,7 +1858,11 @@ impl Game<'_> {
"Do not use unless you know the exact contents!",
),
] {
lines.push((self.translate(key, fallback), rgba(255, 85, 85, 255)));
push(
&mut lines,
self.translate(key, fallback),
rgba(255, 85, 85, 255),
);
}
}
lines
@@ -1863,20 +1893,27 @@ impl Game<'_> {
self.settings.file.bool("advancedItemTooltips")
}
/// `TooltipRenderUtil`: dark box with the purple border, first line spaced.
pub(super) fn draw_tooltip_lines(
pub(super) fn draw_tooltip_lines<T: Clone + Into<horizon_client::text::Text>>(
&self,
batch: &mut HudBatch,
lines: &[(String, u32)],
lines: &[(T, u32)],
x: f32,
y: f32,
) {
if lines.is_empty() {
return;
}
let lines: Vec<_> = lines
.iter()
.map(|(text, color)| {
let text: horizon_client::text::Text = text.clone().into();
(text.resolve_legacy(), *color)
})
.collect();
let atlas = &self.gui.atlas;
let w = lines
.iter()
.map(|(t, _)| HudBatch::text_width(atlas, t))
.map(|(t, _)| atlas.font.width_text(t))
.fold(0.0, f32::max);
let h = if lines.len() == 1 {
8.0
@@ -1929,7 +1966,7 @@ impl Game<'_> {
);
let mut ty = y;
for (i, (text, color)) in lines.iter().enumerate() {
batch.text(atlas, text, x, ty, *color, true);
batch.text_styled(atlas, text, x, ty, *color, true);
ty += if i == 0 { 12.0 } else { 10.0 };
}
}
@@ -1946,7 +1983,12 @@ impl Game<'_> {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(text) = self.crafter_tooltip(slot) {
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
self.draw_tooltip_lines(
batch,
&[(horizon_client::text::Text::literal(text), u32::MAX)],
self.cursor.0,
self.cursor.1,
);
}
}
+50 -2
View File
@@ -127,6 +127,52 @@ impl Game<'_> {
None => vec![own],
}
}
/// `Container.stillValidBlockEntity`: the open block container closes once
/// the player's eyes are out of reach of any of its blocks (a 4-block buffer).
pub(super) fn tick_container_reach(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() {
return;
}
let Some(eye) = self.eye_position() else {
return;
};
if self
.container
.is_none_or(|k| k.1 == super::minecarts::CART_KEY_Y)
{
return;
}
let range = self.reach() + 4.0;
let near = self.container_parts().iter().all(|&((x, y, z), _)| {
horizon_client::lan::guest::within_block_range(eye, range, [x, y, z])
});
if !near {
self.close_container();
self.screen = super::Screen::Playing;
self.capture(true);
}
}
/// `CraftingMenu.stillValid`: the open crafting table closes once its
/// block is no longer a crafting table or the player's eyes are out of
/// reach (a 4-block buffer).
pub(super) fn tick_craft_table(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() || self.craft_size != 3 {
return;
}
let (Some(pos), Some(eye)) = (self.craft_table, self.eye_position()) else {
return;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let table = &*self.res.facts().get(state).name == "minecraft:crafting_table";
let range = self.reach() + 4.0;
if !(table && horizon_client::lan::guest::within_block_range(eye, range, pos)) {
self.close_container();
self.screen = super::Screen::Playing;
self.capture(true);
}
}
/// The number of slots of the open container.
pub(super) fn container_size(&self) -> usize {
self.container_parts().iter().map(|p| p.1).sum()
@@ -227,7 +273,8 @@ impl Game<'_> {
);
let title = self.translate("container.shulkerBox", "Shulker Box");
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
batch.text(atlas, "Inventory", ox + 8.0, oy + 73.0, grey, false);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
return;
}
let rows = self.chest_rows().unwrap_or(3);
@@ -255,9 +302,10 @@ impl Game<'_> {
grey,
false,
);
let inventory = self.translate("container.inventory", "Inventory");
batch.text(
atlas,
"Inventory",
&inventory,
ox + 8.0,
oy + 114.0 + rows as f32 * 18.0 - 94.0,
grey,
+1 -1
View File
@@ -30,7 +30,7 @@ impl Game<'_> {
Some(HeldName {
item: stack.item.to_string(),
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
self.pack.names.get(key).map(str::to_owned)
self.pack.names.display_get(key)
})),
rarity_color: match rarity {
"uncommon" => text::YELLOW,
+1 -1
View File
@@ -399,7 +399,7 @@ impl Game<'_> {
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let names = |id: &str| self.pack.names.item(id);
let names = |id: &str| self.pack.names.display_item(id);
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&names, s))
+2
View File
@@ -92,6 +92,8 @@ impl Game<'_> {
match who {
Some(Who::Player) => {
if !self.creative {
// WitherSkull.onHitEntity's source: the wither launched it.
self.combat.projectile_owner = owner;
self.mob_hits_player_kind(
amount,
[p.previous.x, p.previous.y, p.previous.z],
+129 -54
View File
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -516,6 +516,18 @@ fn sprite_split() -> u32 {
}
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
const SPRITE_ARRAYS: u32 = 8;
/// The layers to give a square texture of `count` layers viewed as a 2D
/// array. wgpu's OpenGL backend picks a texture's target from its layer
/// count when it is created: one layer makes a 2D texture and a multiple of
/// six a cube map, neither of which can be viewed as an array.
fn array_layers(count: u32, gl: bool) -> u32 {
match count {
_ if !gl => count,
0 | 1 => 2,
n if n % 6 == 0 => n + 1,
n => n,
}
}
/// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer
/// stand-in.
fn sprite_views(
@@ -683,25 +695,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends
/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when
/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in
/// the driver can't be caught in the game; the launcher switches the setting
/// to DirectX 12 after one at the renderer's start (#746).
fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec<wgpu::Backends> {
if !windows || wgpu_backend {
return vec![wgpu::Backends::default()];
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
/// before has no surface, adapter or device. A native crash in the driver can't be
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
/// the renderer's start (#746). macOS has Metal only.
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
use wgpu::Backends;
if os == "macos" || wgpu_backend {
return vec![Backends::default()];
}
let windows = os == "windows";
match api {
GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12],
GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN],
GraphicsApi::Dx12 => vec![wgpu::Backends::DX12],
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN],
GraphicsApi::OpenGl => vec![Backends::GL],
}
}
fn backends_name(backends: wgpu::Backends) -> &'static str {
match backends {
wgpu::Backends::VULKAN => "Vulkan",
wgpu::Backends::DX12 => "DirectX 12",
wgpu::Backends::GL => "OpenGL",
_ => "graphics",
}
}
@@ -780,7 +798,7 @@ async fn open_device(
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
cfg!(windows),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
@@ -793,7 +811,13 @@ async fn open_device(
let surface = if offscreen {
None
} else {
Some(instance.create_surface(window.clone())?)
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
@@ -863,14 +887,14 @@ async fn open_device(
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
#[cfg(target_os = "windows")]
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \
driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \
a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
start the game again";
#[cfg(target_os = "macos")]
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL";
/// Logs a step of the renderer's start, for a native crash in the graphics
/// driver: it leaves no panic or crash report, so the log's last line is the
/// only sign of where the game stopped. The log file is written unbuffered;
@@ -1300,13 +1324,17 @@ impl Renderer {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
let sprite_textures: Vec<wgpu::Texture> = prepared
.sprites
.into_iter()
.enumerate()
.map(|(array, (mips, levels))| {
.map(|(array, (mut mips, levels))| {
let first = array as u32 * split;
let count = layers.saturating_sub(first).clamp(1, split);
let used = layers.saturating_sub(first).clamp(1, split);
let count = array_layers(used, gl);
// The extra layers are empty.
mips.resize(mips.len() / used as usize * count as usize, 0);
mip_levels = levels;
device.create_texture_with_data(
&queue,
@@ -1340,6 +1368,7 @@ impl Renderer {
sprite_mips,
&sprite_textures,
split,
animate::SpriteAnimator::compute(&adapter, &device),
);
let sprite_unused = device
.create_texture(&wgpu::TextureDescriptor {
@@ -1347,7 +1376,7 @@ impl Renderer {
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
depth_or_array_layers: array_layers(1, gl),
},
mip_level_count: 1,
sample_count: 1,
@@ -2057,16 +2086,15 @@ impl Renderer {
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
// the shader is specialised, so a pixel pays for none of the others.
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
let (entry, blend) = if i & 4 == 0 {
("fs_screen", alpha_blend)
} else {
("fs_vignette", vignette_blend)
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
let (entry, blend) = match (i & 4 == 0, blur) {
(true, false) => ("fs_screen", alpha_blend),
(true, true) => ("fs_screen_blur", alpha_blend),
(false, false) => ("fs_vignette", vignette_blend),
(false, true) => ("fs_vignette_blur", vignette_blend),
};
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
let constants = [
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
];
let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0;
let constants = [("CLAMP", f64::from(u8::from(clamp)))];
let compilation_options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -2154,15 +2182,26 @@ impl Renderer {
)
});
let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some());
let multi_draw = device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
&& adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
// Without indirect draws (OpenGL before 4.3 without compute shaders)
// even creating an indirect buffer fails; one draw per section reads
// `draws` on the CPU instead.
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section draws"),
size: 1024 * 20,
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
usage: if multi_draw {
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST
} else {
wgpu::BufferUsages::COPY_DST
},
mapped_at_creation: false,
});
let multi_draw = device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
// HORIZON_GPU_CULL=0 turns occlusion culling off, `all` culls every
// frame however few its draws and little it hides, `shader` tests in
// the shader as on Metal (A/B checks).
@@ -3439,6 +3478,7 @@ impl Renderer {
translucent_crumbling_quads,
water_mask_quads,
shadow_quads,
translucent_particle_quads,
] = self.entity_batch.update(
frame.entities,
frame.additive,
@@ -3450,7 +3490,8 @@ impl Renderer {
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads;
+ shadow_quads
+ translucent_particle_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -3849,7 +3890,7 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - shadow_quads) * 4) as i32,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -3917,6 +3958,22 @@ impl Renderer {
);
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
// translucent particle layer draws after translucent terrain —
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
@@ -4423,14 +4480,23 @@ mod tests {
normal: [0; 4],
}; 4]
};
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
.into_iter()
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let entities: Vec<_> = [
(0, 0),
(14, 9),
(4, 1),
(11, 2),
(15, 10),
(7, 3),
(4, 4),
(12, 7),
]
.into_iter()
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1]);
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -4438,29 +4504,31 @@ mod tests {
.iter()
.map(|q| q[0].light)
.collect::<Vec<_>>(),
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
);
}
#[test]
fn windows_tries_vulkan_then_directx_12() {
fn auto_tries_vulkan_then_directx_12_then_opengl() {
use super::{GraphicsApi, backend_order};
use wgpu::Backends;
let order = |api, os| backend_order(api, os, false);
assert_eq!(
backend_order(GraphicsApi::Auto, true, false),
[Backends::VULKAN, Backends::DX12]
order(GraphicsApi::Auto, "windows"),
[Backends::VULKAN, Backends::DX12, Backends::GL]
);
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
assert_eq!(
backend_order(GraphicsApi::Vulkan, true, false),
[Backends::VULKAN]
order(GraphicsApi::Auto, "linux"),
[Backends::VULKAN, Backends::GL]
);
assert_eq!(
backend_order(GraphicsApi::Dx12, true, false),
[Backends::DX12]
);
// WGPU_BACKEND picks the backends itself; other systems have one.
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
for api in GraphicsApi::ALL {
assert_eq!(backend_order(api, true, true), [Backends::default()]);
assert_eq!(backend_order(api, false, false), [Backends::default()]);
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
@@ -4520,6 +4588,13 @@ mod tests {
}
}
#[test]
fn gl_sprite_arrays_are_neither_2d_textures_nor_cube_maps() {
for (count, gl) in [(1, 2), (2, 2), (5, 5), (6, 7), (12, 13), (2048, 2048)] {
assert_eq!(super::array_layers(count, true), gl);
assert_eq!(super::array_layers(count, false), count);
}
}
#[test]
fn entity_shader_validates_with_its_overlay_constants() {
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
naga::valid::Validator::new(
+179 -25
View File
@@ -10,6 +10,10 @@
//! frames. Blending on the CPU at the array's size and copying every changed
//! sprite level by level (a copy command each, a blit each in Mesa) was about
//! a fifth of the render thread on an integrated GPU.
//!
//! Without compute shaders (OpenGL before 4.3, the last fallback) the same
//! jobs run on the CPU, the same integer mix as `animate.wgsl`, and each
//! changed level is copied into its layer.
use super::upload::Uploads;
use crate::mipmap::Settings;
use crate::sprites::{FrameBlend, SpriteAnimation};
@@ -38,17 +42,32 @@ struct Sprite {
struct Group {
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
offset: u64,
/// One bind group per mip level of the array.
/// One bind group per mip level of the array (none on the CPU).
levels: Vec<wgpu::BindGroup>,
/// This tick's jobs.
jobs: Vec<u8>,
/// Its sprite array and its frames, for the CPU.
array: usize,
buffer: usize,
}
/// Where the jobs run.
enum Writer {
Gpu {
jobs: wgpu::Buffer,
pipeline: wgpu::ComputePipeline,
},
/// Every frames buffer's words, and the sprite arrays to copy into.
Cpu {
frames: Vec<Vec<u8>>,
textures: Vec<wgpu::Texture>,
},
}
pub(super) struct SpriteAnimator {
sprites: Vec<Sprite>,
groups: Vec<Group>,
jobs: wgpu::Buffer,
pipeline: wgpu::ComputePipeline,
writer: Writer,
/// Edge of the arrays' layers.
size: u32,
/// The layers the last `animate` rewrote, counted over all arrays.
@@ -57,7 +76,8 @@ pub(super) struct SpriteAnimator {
impl SpriteAnimator {
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
/// wide; `animations` hold their frames at that size.
/// wide; `animations` hold their frames at that size. `compute`: whether
/// the device says it runs compute shaders writing storage textures.
pub fn new(
device: &wgpu::Device,
animations: &[SpriteAnimation],
@@ -65,11 +85,18 @@ impl SpriteAnimator {
settings: &[Settings],
textures: &[wgpu::Texture],
split: u32,
compute: bool,
) -> Self {
let pipeline = if compute { pipeline(device) } else { None };
let limits = device.limits();
let limit = limits
.max_storage_buffer_binding_size
.min(limits.max_buffer_size);
let limit = if pipeline.is_some() {
limits
.max_storage_buffer_binding_size
.min(limits.max_buffer_size)
} else {
// Word offsets.
u64::from(u32::MAX) * 4
};
let mut frames: Vec<Vec<u8>> = Vec::new();
// Each group's sprite array and frames buffer.
let mut keys: Vec<(u32, usize)> = Vec::new();
@@ -119,6 +146,29 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
.map(|(&(array, buffer), &count)| Group {
offset: 0,
levels: Vec::new(),
jobs: Vec::with_capacity(count * JOB_BYTES),
array: array as usize,
buffer,
})
.collect();
return Self {
sprites,
groups,
writer: Writer::Cpu {
frames,
textures: textures.to_vec(),
},
size,
changed: Vec::new(),
};
};
let frames: Vec<_> = frames
.iter()
.map(|data| {
@@ -142,18 +192,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Animated sprites"),
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Animated sprites"),
layout: None,
module: &module,
entry_point: Some("animate"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
@@ -198,19 +236,33 @@ impl SpriteAnimator {
offset,
levels,
jobs: Vec::with_capacity(count * JOB_BYTES),
array: array as usize,
buffer,
}
})
.collect();
Self {
sprites,
groups,
jobs,
pipeline,
writer: Writer::Gpu { jobs, pipeline },
size,
changed: Vec::new(),
}
}
/// Whether the jobs can run on the GPU: compute shaders writing a
/// storage texture and reading two storage buffers (not OpenGL before
/// 4.3).
pub fn compute(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
let limits = device.limits();
adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
&& limits.max_storage_textures_per_shader_stage >= 1
&& limits.max_storage_buffers_per_shader_stage >= 2
}
/// The layers the last `animate` rewrote (for the shader pack atlas).
pub fn changed(&self) -> &[u32] {
&self.changed
@@ -227,14 +279,47 @@ impl SpriteAnimator {
if self.collect(tick) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
Writer::Gpu { jobs, pipeline } => (jobs, pipeline),
Writer::Cpu { frames, textures } => {
for group in &mut self.groups {
let (frames, texture) = (&frames[group.buffer], &textures[group.array]);
for job in group.jobs.as_chunks::<JOB_BYTES>().0 {
let (words, _) = job.as_chunks::<4>();
let job: [u32; 5] = std::array::from_fn(|i| u32::from_le_bytes(words[i]));
for level in 0..texture.mip_level_count() {
let width = self.size >> level;
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: job[0],
},
aspect: wgpu::TextureAspect::All,
},
width,
width,
&mix_level(frames, job, width),
);
}
}
group.jobs.clear();
}
return;
}
};
for group in &self.groups {
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
uploads.buffer(encoder, jobs, group.offset, &group.jobs);
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_pipeline(pipeline);
for group in &mut self.groups {
let count = (group.jobs.len() / JOB_BYTES) as u32;
if count == 0 {
@@ -278,6 +363,62 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Animated sprites"),
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Animated sprites"),
layout: None,
module: &module,
entry_point: Some("animate"),
compilation_options: Default::default(),
cache: None,
});
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
}
}
}
/// `animate.wgsl` on the CPU: one `width` wide level of a job
/// (`[layer, current, next, millis, edge]`) from `frames`' chains.
fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec<u8> {
let mut out = Vec::with_capacity((width * width * 4) as usize);
for y in 0..width {
for x in 0..width {
let texel = if width >= edge {
let scale = width / edge;
y / scale * edge + x / scale
} else {
4 * (edge * edge - width * width) / 3 + y * width + x
};
let at = |chain: u32| ((chain + texel) * 4) as usize;
let a = &frames[at(current)..at(current) + 4];
if millis == 0 {
out.extend_from_slice(a);
continue;
}
let b = &frames[at(next)..at(next) + 4];
out.extend(a.iter().zip(b).map(|(&a, &b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000) as u8
}));
}
}
out
}
/// The mip chain a frame's layer is made of, and its first level's edge.
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
/// array's size from its own size's chain, its first level repeated into the
@@ -512,6 +653,16 @@ mod tests {
#[test]
fn gpu_layers_match_the_cpu_chains_every_tick() {
layers_match_the_cpu_chains_every_tick(true);
}
/// Without compute shaders (OpenGL before 4.3): the same layers.
#[test]
fn copied_layers_match_the_cpu_chains_every_tick() {
layers_match_the_cpu_chains_every_tick(false);
}
fn layers_match_the_cpu_chains_every_tick(compute: bool) {
let Some((device, queue)) = device() else {
return;
};
@@ -552,12 +703,15 @@ mod tests {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
})
.collect();
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
let mut animator =
SpriteAnimator::new(&device, &animations, size, &mips, &textures, split, compute);
let mut uploads = Uploads::new(&device);
let mut writes = Vec::new();
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
+10 -5
View File
@@ -3,13 +3,16 @@ use super::Vertex;
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`).
const MATERIALS: usize = 5;
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -41,6 +44,7 @@ impl EntityBatch {
12 => 2,
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
_ => 0,
};
self.buckets[material][counts[material]] = index;
@@ -92,13 +96,14 @@ mod tests {
) -> (Vec<Vertex>, [u32; MATERIALS]) {
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
let mut counts = [0; MATERIALS];
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
for (i, layer) in [0, 11, 12, 13, 14, 15].into_iter().enumerate() {
for quad in entities.as_chunks::<4>().0 {
let material = match quad[0].layer {
11 => 11,
12 => 12,
13 => 13,
14 => 14,
15 => 15,
_ => 0,
};
if material == layer {
@@ -118,7 +123,7 @@ mod tests {
pos: [index as f32, 2.0, 3.0],
uv: [index as f32, 5.0],
layer: if mixed {
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
} else {
0
},
+24 -11
View File
@@ -98,19 +98,27 @@ impl PresentMode {
/// macOS). Applies at the next start.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GraphicsApi {
/// Vulkan, and DirectX 12 when Vulkan has no adapter or device.
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
#[default]
Auto,
Vulkan,
Dx12,
/// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux).
OpenGl,
}
impl GraphicsApi {
pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12];
pub const ALL: [GraphicsApi; 4] = [
GraphicsApi::Auto,
GraphicsApi::Vulkan,
GraphicsApi::Dx12,
GraphicsApi::OpenGl,
];
pub fn name(self) -> &'static str {
match self {
GraphicsApi::Auto => "auto",
GraphicsApi::Vulkan => "vulkan",
GraphicsApi::Dx12 => "dx12",
GraphicsApi::OpenGl => "opengl",
}
}
pub fn label(self) -> &'static str {
@@ -118,6 +126,7 @@ impl GraphicsApi {
GraphicsApi::Auto => "Auto",
GraphicsApi::Vulkan => "Vulkan",
GraphicsApi::Dx12 => "DirectX 12",
GraphicsApi::OpenGl => "OpenGL",
}
}
/// The API a name stands for, ignoring case; `None` for anything else.
@@ -126,7 +135,7 @@ impl GraphicsApi {
.into_iter()
.find(|api| api.name().eq_ignore_ascii_case(s.trim()))
}
/// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting.
/// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting.
pub fn with_env(self) -> Self {
std::env::var("HORIZON_GRAPHICS_API")
.ok()
@@ -376,14 +385,18 @@ impl Graphics {
if self.workers > 0 {
return self.workers as usize;
}
// The pool never takes every core. The render thread and the
// integrated server thread (single player runs on its own server
// thread by default) each need one, and a pool that fills every core
// starves them: the frame rate collapses to single digits while the
// workers saturate the CPU and its memory bandwidth.
// Vanilla's count (Util.maxAllowedExecutorThreads: every core but
// one). The pool never takes every core: a pool that fills them all
// starves the render and server threads, and the frame rate collapses
// while the workers saturate the CPU and its memory bandwidth. One
// core left is enough with the workers behind those two threads
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(2)
.saturating_sub(1)
.max(1);
if self.battery { auto.div_ceil(2) } else { auto }
}
@@ -546,11 +559,11 @@ mod tests {
}
#[test]
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
fn automatic_workers_leave_a_core_as_vanilla_does() {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
assert_eq!(g.worker_threads(), cores.saturating_sub(2).max(1));
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+17 -1
View File
@@ -3,7 +3,7 @@
//! GUI scale is applied when drawing).
use crate::Result;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
@@ -58,6 +58,9 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
/// sample as vanilla's defaults (nearest, repeat).
pub flags: HashMap<String, TextureFlags>,
@@ -462,7 +465,16 @@ impl HudAtlas {
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
let mut pixels = vec![0u8; (width * height * 4) as usize];
let mut sprites = HashMap::new();
let mut translucent = HashSet::new();
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
if data
.as_chunks::<4>()
.0
.iter()
.any(|p| p[3] != 0 && p[3] != 255)
{
translucent.insert(name.clone());
}
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
@@ -527,6 +539,7 @@ impl HudAtlas {
width,
height,
sprites,
translucent,
flags,
fire_animations,
font,
@@ -890,6 +903,9 @@ impl HudBatch {
color: u32,
shadow: bool,
) -> f32 {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
let text = text.resolved();
let font = &atlas.font;
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
+355
View File
@@ -0,0 +1,355 @@
//! Item stacks on the wire (`ItemStack.OPTIONAL_STREAM_CODEC`): the component
//! patch read back as vanilla's `components` compound, so the decoder the saved
//! and command item NBT use fills every field. Both sides use it: a client for
//! what its server sends, a server for a creative client's
//! `SetCreativeModeSlot`. The other direction is `lan::guest::stack_to_network`.
use crate::items::ItemStack;
use horizon_protocol::item::{Component, Profile, Stack, component};
use horizon_protocol::registry::{Builtin, Dynamic};
use horizon_world::nbt::Tag;
use std::sync::Arc;
/// A stack from the wire: its item by the built-in registry's id, its
/// components by the synced registries' (enchantments, trims).
pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack {
let mut out = ItemStack {
item: item_name(builtin, s.item),
count: s.count.max(0) as u32,
damage: 0,
extra: None,
};
let components = components_tag(s, builtin, synced);
let (damage, data) = crate::item_data::decode_components(components.as_ref());
out.damage = damage;
if let Some(data) = data {
*out.data_mut() = *data;
}
out.tidy();
out
}
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
Arc::from(
builtin
.get("minecraft:item")
.and_then(|i| i.name(id))
.unwrap_or("minecraft:air"),
)
}
/// The item's patched components as vanilla's `components` compound.
fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag> {
let name = |id: i32| builtin.get("minecraft:data_component_type")?.name(id);
let mut entries: Vec<(&str, Tag)> = Vec::new();
for (kind, c) in &s.added {
let Some(key) = name(*kind) else {
continue;
};
let value = match c {
// Read past, not decoded: the item keeps its prototype's value.
Component::Raw(_) => continue,
// Trim materials and patterns are synced registries, as enchantments are.
Component::Trim { material, pattern } => {
let (Some(material), Some(pattern)) = (
synced.name("minecraft:trim_material", *material),
synced.name("minecraft:trim_pattern", *pattern),
) else {
continue;
};
Tag::compound([
("material", Tag::String(material.into())),
("pattern", Tag::String(pattern.into())),
])
}
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
}
Component::Int(v) => Tag::Int(*v),
Component::Unit => Tag::Compound(Default::default()),
Component::Bool(v) => Tag::Byte(i8::from(*v)),
Component::Text(t) | Component::Nbt(t) => t.clone(),
Component::Ident(v) => Tag::String(v.as_str().into()),
Component::Lore(lines) => Tag::List(lines.clone()),
Component::WritableBook(pages) => Tag::compound([(
"pages",
Tag::List(
pages
.iter()
.map(|p| Tag::String(p.as_str().into()))
.collect(),
),
)]),
Component::WrittenBook(b) => Tag::compound([
("title", Tag::String(b.title.as_str().into())),
("author", Tag::String(b.author.as_str().into())),
("generation", Tag::Int(b.generation)),
("pages", Tag::List(b.pages.clone())),
("resolved", Tag::Byte(i8::from(b.resolved))),
]),
Component::Fireworks { flight, explosions } => Tag::compound([
("flight_duration", Tag::Byte(*flight as i8)),
(
"explosions",
Tag::List(explosions.iter().map(fireworks_tag).collect()),
),
]),
Component::FireworkExplosion(e) => fireworks_tag(e),
Component::Enchantments(list) => {
// Enchantments are a synced (data-driven) registry: the
// ids index the server's RegistryData, not the builtin
// report, so a datapack enchantment names correctly.
let levels = list.iter().filter_map(|(id, level)| {
let name = synced.name("minecraft:enchantment", *id)?;
Some((name, Tag::Int(*level)))
});
Tag::compound(levels)
}
Component::AttackRange(v) => Tag::compound([
("min_reach", Tag::Float(v[0].to_bits())),
("max_reach", Tag::Float(v[1].to_bits())),
("min_attack_momentum", Tag::Float(v[2].to_bits())),
("max_attack_momentum", Tag::Float(v[3].to_bits())),
("hitbox_margin", Tag::Float(v[4].to_bits())),
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// PotionContents' NBT: the potion by name and the custom colour.
Component::Potion { potion, color, .. } => {
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
Tag::compound(
potion
.map(|name| ("potion", Tag::String(name.into())))
.into_iter()
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
)
}
// ItemStackTemplate.CODEC: id, count and components.
Component::Stacks(stacks) => Tag::List(
stacks
.iter()
.map(|t| {
let mut entries = vec![
(
"id",
Tag::String(item_name(builtin, t.item).as_ref().into()),
),
("count", Tag::Int(t.count)),
];
if let Some(c) = components_tag(t, builtin, synced) {
entries.push(("components", c));
}
Tag::compound(entries)
})
.collect(),
),
Component::CustomModelData {
floats,
flags,
strings,
colors,
} => Tag::compound([
(
"floats",
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
),
(
"flags",
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
),
(
"strings",
Tag::List(
strings
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
(
"colors",
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
),
]),
Component::TooltipDisplay { hide, hidden } => {
let hidden = hidden
.iter()
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
Tag::compound([
("hide_tooltip", Tag::Byte(i8::from(*hide))),
("hidden_components", Tag::List(hidden.collect())),
])
}
};
entries.push((key, value));
}
Some(Tag::compound(entries))
}
/// A wire `FireworkExplosion` as vanilla's NBT codec writes it
/// (`FireworkExplosion.Shape`'s BY_ID order).
fn fireworks_tag(e: &horizon_protocol::item::FireworkExplosion) -> Tag {
const SHAPES: [&str; 5] = ["small_ball", "large_ball", "star", "creeper", "burst"];
let ints = |c: &[i32]| Tag::List(c.iter().map(|c| Tag::Int(*c)).collect());
Tag::compound([
(
"shape",
Tag::String(
SHAPES
.get(e.shape.max(0) as usize)
.copied()
.unwrap_or("small_ball")
.into(),
),
),
("colors", ints(&e.colors)),
("fade_colors", ints(&e.fade)),
("has_trail", Tag::Byte(i8::from(e.trail))),
("has_twinkle", Tag::Byte(i8::from(e.twinkle))),
])
}
/// A `minecraft:profile` component as the item data JSON the model reads.
fn profile_tag(p: &Profile) -> Tag {
let properties = p
.properties
.iter()
.map(|(name, value, signature)| {
let mut entries: Vec<(&'static str, Tag)> = vec![
("name", Tag::String(name.as_str().into())),
("value", Tag::String(value.as_str().into())),
];
if let Some(signature) = signature {
entries.push(("signature", Tag::String(signature.as_str().into())));
}
Tag::compound(entries)
})
.collect::<Vec<_>>();
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
if let Some(name) = &p.name {
entries.push(("name", Tag::String(name.as_str().into())));
}
entries.push(("properties", Tag::List(properties)));
Tag::compound(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::registry::Ids;
/// What a creative client sends (`stack_to_network`) comes back whole on
/// the server: damage, both enchantment lists, repair cost, name, trim.
#[test]
fn a_creative_stack_keeps_its_components_across_the_wire() {
let mut components = vec![String::new(); 60];
for (id, name) in [
(component::DAMAGE, "minecraft:damage"),
(component::CUSTOM_NAME, "minecraft:custom_name"),
(component::ENCHANTMENTS, "minecraft:enchantments"),
(component::POTION_CONTENTS, "minecraft:potion_contents"),
(component::REPAIR_COST, "minecraft:repair_cost"),
(
component::STORED_ENCHANTMENTS,
"minecraft:stored_enchantments",
),
(component::TRIM, "minecraft:trim"),
] {
components[id as usize] = name.to_owned();
}
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let builtin = Builtin {
registries: [
(
"minecraft:item".to_owned(),
Ids::from_names(names(&[
"minecraft:air",
"minecraft:diamond_chestplate",
"minecraft:splash_potion",
])),
),
(
"minecraft:data_component_type".to_owned(),
Ids::from_names(components),
),
(
"minecraft:potion".to_owned(),
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
),
]
.into(),
};
let synced = |n: &[&str]| n.iter().map(|s| (s.to_string(), None)).collect::<Vec<_>>();
let dynamic = Dynamic {
registries: [
(
"minecraft:enchantment".to_owned(),
synced(&["minecraft:protection", "minecraft:sharpness"]),
),
(
"minecraft:trim_material".to_owned(),
synced(&["minecraft:gold"]),
),
(
"minecraft:trim_pattern".to_owned(),
synced(&["minecraft:sentry"]),
),
]
.into(),
};
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
stack.damage = 5;
stack.enchant("minecraft:protection", 4);
let d = stack.data_mut();
d.stored_enchantments
.push((Arc::from("minecraft:sharpness"), 2));
d.repair_cost = 9;
d.custom_name = Some("Keeper".to_owned());
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
let id = |registry: &str, name: &str| {
builtin
.get(registry)
.and_then(|ids| ids.id(name))
.or_else(|| dynamic.id(registry, name))
};
let wire = crate::lan::guest::stack_to_network(
|name| {
builtin
.get("minecraft:item")
.and_then(|i| i.id(name))
.unwrap_or(0)
},
id,
&Some(stack.clone()),
)
.unwrap();
let back = from_network(&wire, &builtin, &dynamic);
assert_eq!(back.item, stack.item);
assert_eq!(back.damage, 5);
let d = back.data().expect("components");
assert_eq!(d.enchantments, vec![(Arc::from("minecraft:protection"), 4)]);
assert_eq!(
d.stored_enchantments,
vec![(Arc::from("minecraft:sharpness"), 2)]
);
assert_eq!(d.repair_cost, 9);
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
assert_eq!(
d.trim,
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
);
// PotionContents: the potion is a built-in registry, as a hub's
// `SyncData::id` treats it, so the id has to come from there.
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
let wire = crate::lan::guest::stack_to_network(
|name| id("minecraft:item", name).unwrap_or(0),
id,
&Some(potion),
)
.unwrap();
assert_eq!(
from_network(&wire, &builtin, &dynamic).potion(),
Some("minecraft:swiftness")
);
}
}
+50 -6
View File
@@ -487,23 +487,31 @@ pub fn lines(
) -> Vec<String> {
let mut lines = vec![name_line(stack, translate)];
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
lines.extend(lore_lines(stack));
lines.extend(lore_texts(stack).into_iter().map(|t| t.plain()));
}
lines.extend(detail_lines(stack, info, translate));
lines
}
/// `LoreComponent.addToTooltip`: the saved component lines, as plain text.
fn lore_lines(stack: &ItemStack) -> Vec<String> {
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
color: Some(crate::text::DARK_PURPLE),
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let json = crate::scoreboard::tag_to_json(entry);
let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s));
text.plain()
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
for span in &mut text.spans {
span.style.inherit(&base);
}
text
})
.collect()
}
@@ -512,6 +520,42 @@ fn lore_lines(stack: &ItemStack) -> Vec<String> {
mod tests {
use super::*;
#[test]
fn lore_keeps_component_colours_under_dark_purple_italic() {
let mut stack = ItemStack::new("minecraft:stick", 1);
stack.data_mut().set_other(
"minecraft:lore",
Some(Tag::List(vec![
Tag::compound([
("text", Tag::String("Gold line".into())),
("color", Tag::String("gold".into())),
("italic", Tag::Byte(0)),
]),
Tag::String("Plain lore".into()),
])),
);
let lines = lore_texts(&stack);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].plain(), "Gold line");
assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD));
assert!(!lines[0].spans[0].style.is_italic());
assert_eq!(lines[1].plain(), "Plain lore");
assert_eq!(
lines[1].spans[0].style.color,
Some(crate::text::DARK_PURPLE)
);
assert!(lines[1].spans[0].style.is_italic());
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
stack.data_mut().set_other(
"minecraft:tooltip_display",
Some(Tag::compound([(
"hidden_components",
Tag::List(vec![Tag::String("minecraft:lore".into())]),
)])),
);
assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn tooltip_display_controls_name_and_component_visibility() {
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
@@ -343,7 +343,21 @@ impl Hub {
if !window_ok || self.guests[i].spectator() {
return Vec::new();
}
// A menu that is no longer valid takes no clicks.
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
{
return Vec::new();
}
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
let shulker_box = self
.sync
.builtin
.get("minecraft:menu")
.and_then(|m| m.id("minecraft:shulker_box"));
let shulker = in_menu
&& shulker_box.is_some()
&& self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box;
let mut drag = if in_menu {
self.guests[i].menu.as_mut().and_then(|m| m.drag.take())
} else {
@@ -357,6 +371,7 @@ impl Hub {
let mut view = View {
guest,
container: items.as_deref_mut(),
shulker,
};
let (drops, changed) = menu::click(
&mut view,
+98
View File
@@ -223,6 +223,8 @@ pub struct Guest {
/// Whether the guest was on the ground before the first of `moves`.
pub moves_from_ground: bool,
pub dead: bool,
/// `LivingEntity.deathTime`: ticks since the guest died.
pub death_time: u32,
/// The join packets were sent (PlayerList.placeNewPlayer ran).
pub placed: bool,
/// The level the player is in (`ServerPlayer.level().dimension()`): the
@@ -349,6 +351,7 @@ impl Guest {
moves: Vec::new(),
moves_from_ground: false,
dead: false,
death_time: 0,
placed: false,
dimension: "minecraft:overworld".into(),
menu: None,
@@ -404,6 +407,17 @@ impl Guest {
pub fn eye_height(&self) -> f64 {
if self.sneaking { 1.27 } else { 1.62 }
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
pub fn within_block_range(&self, pos: [i32; 3], buffer: f64) -> bool {
let eye = [
self.position[0],
self.position[1] + self.eye_height(),
self.position[2],
];
let range = if self.creative() { 5.0 } else { 4.5 };
within_block_range(eye, range + buffer, pos)
}
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
/// 45 off hand.
@@ -431,6 +445,43 @@ impl Guest {
}
}
/// `FireworkExplosion.STREAM_CODEC`'s fields (`Shape` by its BY_ID order).
fn network_explosion(
e: &crate::particles::FireworkExplosion,
) -> horizon_protocol::item::FireworkExplosion {
horizon_protocol::item::FireworkExplosion {
shape: e.shape as i32,
colors: e.colors.iter().map(|c| *c as i32).collect(),
fade: e.fade.iter().map(|c| *c as i32).collect(),
trail: e.trail,
twinkle: e.twinkle,
}
}
/// A rocket's `minecraft:fireworks` and a star's `minecraft:firework_explosion`.
pub fn firework_components(
d: &crate::item_data::StackData,
) -> Vec<(i32, horizon_protocol::item::Component)> {
use horizon_protocol::item::{Component, component};
let mut out = Vec::new();
if let Some((flight, explosions)) = &d.fireworks {
out.push((
component::FIREWORKS,
Component::Fireworks {
flight: i32::from(*flight),
explosions: explosions.iter().map(network_explosion).collect(),
},
));
}
if let Some(e) = &d.firework_explosion {
out.push((
component::FIREWORK_EXPLOSION,
Component::FireworkExplosion(network_explosion(e)),
));
}
out
}
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
/// `synced_id` names an entry of a synced registry (enchantments, trim
/// materials and patterns) by its id on this connection.
@@ -521,6 +572,23 @@ fn network_stack(
horizon_protocol::item::Component::Int(i32::from(mode)),
));
}
stack.added.extend(firework_components(d));
// PotionContents: the potion by its `minecraft:potion` id.
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
synced_id(
"minecraft:potion",
&horizon_world::datafix::dynamic::namespaced(potion),
)
}) {
stack.added.push((
horizon_protocol::item::component::POTION_CONTENTS,
horizon_protocol::item::Component::Potion {
potion: Some(id),
color: None,
rest: Vec::new(),
},
));
}
// A loaded crossbow's ammunition.
if !d.charged.is_empty() {
let charged = d
@@ -568,11 +636,41 @@ fn network_stack(
Some(stack)
}
/// `Player.isWithinBlockInteractionRange`: `eye` closer than `range` to the
/// box of the block at `pos` (`AABB.distanceToSqr`).
pub fn within_block_range(eye: [f64; 3], range: f64, pos: [i32; 3]) -> bool {
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< range * range
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::item::{Component, component};
#[test]
fn potions_go_as_potion_registry_ids() {
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:potion", "minecraft:swiftness") => Some(14),
_ => None,
};
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
let stack = stack_to_network(|_| 7, synced_id, &Some(potion)).unwrap();
assert_eq!(
stack.component(component::POTION_CONTENTS),
Some(&Component::Potion {
potion: Some(14),
color: None,
rest: Vec::new(),
})
);
}
#[test]
fn trims_go_as_synced_registry_references() {
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
+27
View File
@@ -149,6 +149,9 @@ pub struct Hub {
pub singleplayer_owner: Option<u128>,
/// Who rides what, set by the game before each tick.
pub riding: super::entity::Riding,
/// The scoreboard's teams, set by the game each tick, for players'
/// display names.
pub teams: Vec<crate::scoreboard::Team>,
last_clock: i64,
last_rate: f32,
ticking: (f32, bool),
@@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag {
])
}
impl Hub {
/// Player.getDisplayName: the name formatted for the player's team
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
/// player on hover and inserting the name.
pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag {
let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else {
return display_name(uuid, name);
};
let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name));
c["click_event"] =
serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")});
c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player",
"uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name});
c["insertion"] = name.into();
crate::scoreboard::component_tag(&c)
}
}
/// A yellow translation of player names (`multiplayer.player.joined`).
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
Tag::compound([
@@ -315,6 +336,7 @@ impl Hub {
entity_types: Default::default(),
entities: Default::default(),
entity_out: Vec::new(),
teams: Vec::new(),
}
}
@@ -2293,6 +2315,11 @@ impl Hub {
.name("minecraft:enchantment", id)
.map(str::to_owned)
}
/// A stack a client sent (`SetCreativeModeSlot`), read with this
/// server's registries.
pub fn stack_from_network(&self, s: &horizon_protocol::item::Stack) -> crate::items::ItemStack {
crate::item_sync::from_network(s, &self.sync.builtin, &self.sync.dynamic)
}
/// ServerLevel.removePlayerImmediately, then addRespawnedPlayer: the other players
/// lose the dead player's entity (ServerEntity.removePairing) and are sent the
+6 -6
View File
@@ -2,7 +2,7 @@
//! handle it: chat sessions validated against the services' keys, signed
//! messages checked link by link, the acknowledged window, and messages
//! relayed with each connection's numbering and signature cache.
use super::{HostEvent, Hub, display_name};
use super::{HostEvent, Hub};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
@@ -214,7 +214,7 @@ impl Hub {
}
let bound = play::ChatTypeBound {
chat_type: self.chat_type,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
let line = format!("<{name}> {}", message.content);
@@ -427,7 +427,7 @@ impl Hub {
let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone());
let bound = play::ChatTypeBound {
chat_type: self.chat_type_id(chat_type),
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
let line = match chat_type {
@@ -467,15 +467,15 @@ impl Hub {
self.chat_type_id("msg_command_incoming"),
);
for &t in targets {
let target = display_name(self.guests[t].uuid, &self.guests[t].name);
let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name);
let out = play::ChatTypeBound {
chat_type: outgoing,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: Some(target),
};
let inc = play::ChatTypeBound {
chat_type: incoming,
name: display_name(uuid, &name),
name: self.player_display_name(uuid, &name),
target: None,
};
match &message {
+75 -13
View File
@@ -64,6 +64,19 @@ impl Slots {
.iter()
.all(|k| world.block_entities.contains_key(k))
}
/// `Container.stillValid` for the guest: the block entities are there
/// and the guest is within reach of each (`stillValidBlockEntity`). The
/// ender chest's block is `at` (`PlayerEnderChestContainer.activeChest`).
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
let blocks = match self {
Slots::Ender => at.into_iter().collect(),
_ => self.halves(),
};
self.valid(world)
&& blocks
.iter()
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
}
/// Container slot `n`.
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
if self == Slots::Ender {
@@ -111,6 +124,8 @@ pub struct View<'a> {
pub guest: &'a mut Guest,
/// The container's slots, or None for the inventory menu.
pub container: Option<&'a mut [Option<ItemStack>]>,
/// The container is a shulker box (`ShulkerBoxSlot`).
pub shulker: bool,
}
impl View<'_> {
@@ -153,6 +168,12 @@ impl View<'_> {
if self.container.is_none() && (5..=8).contains(&i) {
return rules.armor_index(&stack.item) == Some(i - 5);
}
if self.shulker
&& self.container.as_ref().is_some_and(|c| i < c.len())
&& !crate::menu::fits_inside_container_items(stack)
{
return false;
}
self.enabled(i)
}
fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 {
@@ -665,6 +686,24 @@ mod tests {
)
}
/// The prepared resources' items, or None (the test is skipped) without them.
fn rules() -> Option<ItemRules> {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("items.json").exists() {
eprintln!("skipping resource-backed menu check: {}", root.display());
return None;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, &registry).unwrap();
Some(ItemRules(Items::load(&root, &registry, &facts).unwrap()))
}
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
let inside = stack.as_ref().and_then(|s| s.data());
inside.map_or(Vec::new(), |d| {
@@ -680,20 +719,9 @@ mod tests {
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
#[test]
fn guest_chest_clicks_fill_and_empty_bundles() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("items.json").exists() {
eprintln!("skipping resource-backed bundle check: {}", root.display());
let Some(rules) = rules() else {
return;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, &registry).unwrap();
let rules = ItemRules(Items::load(&root, &registry, &facts).unwrap());
};
let mut g = guest();
let mut chest = vec![
Some(ItemStack::new("minecraft:stone", 48)),
@@ -704,6 +732,7 @@ mod tests {
let mut view = View {
guest: &mut g,
container: Some(&mut chest),
shulker: false,
};
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
// Left click with the bundle: the stone goes in.
@@ -736,4 +765,37 @@ mod tests {
);
assert_eq!(contents(&view.get(2)), []);
}
#[test]
fn guest_shulker_box_menu_refuses_shulker_boxes() {
let Some(rules) = rules() else {
return;
};
for shulker in [true, false] {
let mut g = guest();
let mut container = vec![None; 27];
let mut drag = None;
let mut view = View {
guest: &mut g,
container: Some(&mut container),
shulker,
};
// Carried onto an empty slot, shift-clicked from the hotbar
// (menu slot 27 + 27) and swapped in with the number key 1.
view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1));
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
assert_eq!(view.get(0).is_none(), shulker);
view.guest.carried = None;
view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1));
click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules);
assert_eq!(view.guest.inventory[0].is_some(), shulker);
view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1));
click(&mut view, &mut drag, 5, 0, input::SWAP, &rules);
assert_eq!(view.get(5).is_none(), shulker);
// Anything else goes in.
view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1));
click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules);
assert!(view.get(9).is_some());
}
}
}
+5
View File
@@ -138,6 +138,11 @@ impl Language {
pub fn get(&self, key: &str) -> Option<&str> {
self.map.get(key).map(String::as_str)
}
/// The resolved map, for `horizon_protocol::text::plain`: a server's
/// components carry keys, and the network layer resolves them with this.
pub fn map(&self) -> &HashMap<String, String> {
&self.map
}
pub fn len(&self) -> usize {
self.map.len()
}
+1
View File
@@ -96,6 +96,7 @@ pub mod inv_sort;
pub mod inventory_effects;
pub mod item_data;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod items;
pub mod java_text;
+8 -7
View File
@@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 {
.map_or(i32::MAX, |i| i as i32 + 1)
}
/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker
/// boxes cannot nest.
pub fn fits_inside_container_items(stack: &ItemStack) -> bool {
!stack.item.ends_with("shulker_box")
}
/// The containers a menu spans.
const PLAYER: usize = 0;
const MAIN: usize = 1;
@@ -995,7 +1001,7 @@ impl Menu {
fn anvil_result(&mut self, env: &Env) {
let hover = self.containers[MAIN][0]
.as_ref()
.map(|i| crate::stations::hover_name(&|id| env.names.item(id), i))
.map(|i| crate::stations::hover_name(&|id| env.names.display_item(id), i))
.unwrap_or_default();
let out = crate::stations::anvil_result(
env.enchantments,
@@ -1210,11 +1216,6 @@ impl Menu {
env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some())
}
/// `Item.canFitInsideContainerItems`: shulker boxes cannot nest.
fn fits_inside(stack: &ItemStack) -> bool {
!(stack.item.ends_with("shulker_box"))
}
// ---- Slot ----
fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool {
@@ -1247,7 +1248,7 @@ impl Menu {
.as_deref()
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
}),
Role::Shulker => Self::fits_inside(stack),
Role::Shulker => fits_inside_container_items(stack),
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
Role::Output | Role::CraftResult | Role::Inert => false,
Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1),
+6
View File
@@ -7,6 +7,7 @@
//! behaviour, models, animations and sounds.
pub mod ai;
pub mod allay;
pub mod ambient;
pub mod anim;
pub mod animal_brain;
pub mod aquatic;
@@ -1646,6 +1647,11 @@ impl Mob {
pub fn species(&self) -> Species {
self.kind.species
}
/// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's
/// `mainHand` option for a player mob.
pub fn main_arm_left(&self) -> bool {
self.extra.main_hand_left || self.extra.left_handed
}
/// The player hit this mob (melee): LivingEntity.hurt with knockback.
pub fn hurt(
&mut self,
+55
View File
@@ -0,0 +1,55 @@
//! What a client's copy of a mob makes on its own, in the
//! `level().isClientSide()` branches of `aiStep`: nothing of it reaches the
//! server or goes over the network, so the window's game runs it, whether it
//! simulates the world (in-frame) or follows a server.
use super::{Mob, MobEvent, Species};
use horizon_core::random::RandomSource;
/// `Enderman.aiStep`, `Endermite.aiStep` and `Blaze.aiStep` on a client:
/// two portal particles a tick around endermen and endermites; two large
/// smoke particles a tick around blazes and, one tick in 24, their burn.
pub fn client_ai_step(m: &mut Mob, out: &mut Vec<MobEvent>) {
let species = m.species();
if !matches!(
species,
Species::Enderman | Species::Endermite | Species::Blaze
) {
return;
}
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let p = m.position;
if species == Species::Blaze && m.random.next_int_bound(24).unwrap_or(1) == 0 && !m.silent {
let volume = 1.0 + m.random.next_float();
let pitch = m.random.next_float() * 0.7 + 0.3;
out.push(MobEvent::LocalSound(
"entity.blaze.burn",
[p.x + 0.5, p.y + 0.5, p.z + 0.5],
volume,
pitch,
));
}
for _ in 0..2 {
// Entity.getRandomX(0.5), getRandomY(), getRandomZ(0.5).
let r = &mut m.random;
let x = p.x + w * (2.0 * r.next_double() - 1.0) * 0.5;
let y = p.y + h * r.next_double();
let z = p.z + w * (2.0 * r.next_double() - 1.0) * 0.5;
let (kind, at, velocity) = if species == Species::Blaze {
("large_smoke", [x, y, z], [0.0; 3])
} else {
// The enderman's are a quarter block lower.
let y = if species == Species::Enderman {
y - 0.25
} else {
y
};
let velocity = [
(r.next_double() - 0.5) * 2.0,
-r.next_double(),
(r.next_double() - 0.5) * 2.0,
];
("portal", [x, y, z], velocity)
};
out.push(MobEvent::ParticleAt { kind, at, velocity });
}
}
+2 -2
View File
@@ -642,7 +642,7 @@ pub fn setup(
} else {
ArmPose::Empty
};
let mut h = if m.extra.main_hand_left {
let mut h = if m.main_arm_left() {
Hum::new(ArmPose::Empty, main)
} else {
Hum::new(main, ArmPose::Empty)
@@ -693,7 +693,7 @@ pub fn setup(
} else {
ArmPose::Empty
};
let mut h = if m.extra.main_hand_left {
let mut h = if m.main_arm_left() {
Hum::new(off, pose)
} else {
Hum::new(pose, off)
+5
View File
@@ -621,8 +621,10 @@ impl BowAttack {
}
}
}
/// `LivingEntity.isHolding(Items.BOW)`: the bow can sit in either hand.
fn holding_bow(m: &Mob) -> bool {
m.extra.main_hand.as_deref() == Some("minecraft:bow")
|| m.extra.off_hand.as_deref() == Some("minecraft:bow")
}
impl Goal for BowAttack {
goal_clone!();
@@ -715,7 +717,10 @@ impl Goal for BowAttack {
} else {
self.attack_time -= 1;
if self.attack_time <= 0 && self.see_time >= -60 {
// mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand):
// the off hand when the main one doesn't hold the bow.
m.extra.using_item = 1;
m.extra.using_off_hand = m.extra.main_hand.as_deref() != Some("minecraft:bow");
}
}
}
+4
View File
@@ -26,6 +26,9 @@ pub struct Installed {
pub problem: Option<String>,
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
pub dev: bool,
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
}
impl ModList {
@@ -96,6 +99,7 @@ mod tests {
description: String::new(),
problem: None,
dev,
has_settings: false,
};
let mut list = ModList {
installed: vec![installed("a", false), installed("b", true)],
+177 -18
View File
@@ -167,6 +167,9 @@ pub enum MotionEvent {
InsidePortal {
pos: BlockPos,
},
/// `WitherRoseBlock.entityInside`: the player's box swept through a
/// wither rose; the server adds its Wither unless peaceful.
InsideWitherRose,
/// `ServerPlayer.checkFallDamage` after a mace smash: block particles
/// from the landing block, more the further the fall.
SmashLanding {
@@ -1083,11 +1086,26 @@ impl MotionWorld<'_, '_, '_> {
}
/// What `applyEffectsFromBlocks` does to a server's player along the
/// moves its client sent since `from` (in its history): the damage of
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
/// moves it, so the slowing and the rest are the client's.
/// magma, cactus, lit campfires and grown sweet berry bushes, and powder
/// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage.
/// Its client moves it, so the slowing and the rest are the client's.
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
self.player.old_position = from;
self.effects_from_blocks(true)
// Entity.baseTick
self.player.was_in_powder_snow = self.player.in_powder_snow;
self.player.in_powder_snow = false;
self.effects_from_blocks(true)?;
// LivingEntity.aiStep "freezing" (server).
if !self.player.in_powder_snow || !self.player.can_freeze() {
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
}
if self.player.health.tick_count % 40 == 0
&& self.player.is_fully_frozen()
&& self.player.can_freeze()
{
self.freeze_damage()?;
}
Ok(())
}
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
use horizon_world::entity_move::MovementRecord;
@@ -1191,7 +1209,7 @@ impl MotionWorld<'_, '_, '_> {
groups += 1;
last_step = step;
}
if groups == 1 {
if groups == 1 && !damage_only {
// A later group finds the fire already out.
self.player.powder_snow_entered.push(p);
}
@@ -1259,21 +1277,22 @@ impl MotionWorld<'_, '_, '_> {
continue;
}
let powder_snow = name == "minecraft:powder_snow";
let lava_cauldron = name == "minecraft:lava_cauldron";
// getEntityInsideCollisionShape is the whole block but for powder
// snow (its collision shape, the whole block when it is empty),
// filled cauldrons, end portals and tripwires; Horizon doesn't
// follow the player into those last three.
// follow the player into the water and powder snow cauldrons, end
// portals or tripwires yet.
if matches!(
name,
"minecraft:water_cauldron"
| "minecraft:lava_cauldron"
| "minecraft:powder_snow_cauldron"
| "minecraft:end_portal"
| "minecraft:tripwire"
) {
continue;
}
let inside = !powder_snow || {
let inside = !(powder_snow || lava_cauldron) || {
let context = self.context();
let binding = self.collision_binding;
let shape = binding.collision_shape_with_context(
@@ -1282,21 +1301,33 @@ impl MotionWorld<'_, '_, '_> {
p,
&context,
)?;
shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || {
let boxes: Vec<_> = shape
.moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)))
.boxes()
.to_vec();
self.player
.dimensions
.bounds(from)
.collided_along_vector(to.subtract(from), &boxes)
}
powder_snow && (shape.is_empty() || shape.boxes() == VoxelShape::block().boxes())
|| {
let offset = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
let mut boxes: Vec<_> = shape.moved(offset).boxes().to_vec();
if lava_cauldron {
// LavaCauldronBlock.FILLED_SHAPE: the cauldron's
// shape or its lava, Block.column(12, 4, 15).
boxes.push(Aabb::new(
Vec3::new(2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0).add_vector(offset),
Vec3::new(14.0 / 16.0, 15.0 / 16.0, 14.0 / 16.0).add_vector(offset),
));
}
self.player
.dimensions
.bounds(from)
.collided_along_vector(to.subtract(from), &boxes)
}
};
if inside && visited.insert(p.pack()) {
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
// CactusBlock, CampfireBlock and LavaCauldronBlock.entityInside
// (`entity.hurt`), and WitherRoseBlock's server-side effect.
match name {
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
"minecraft:lava_cauldron" => self.lava_cauldron_inside()?,
"minecraft:wither_rose" => {
self.player.events.push(MotionEvent::InsideWitherRose);
}
"minecraft:campfire" | "minecraft:soul_campfire" => {
let lit = self
.assets
@@ -1316,6 +1347,8 @@ impl MotionWorld<'_, '_, '_> {
"minecraft:sweet_berry_bush" if damage_only => {
self.sweet_berry_bush_damage(state)?;
}
// PowderSnowBlock.entityInside's FREEZE.
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
_ => {}
}
if damage_only {
@@ -1354,6 +1387,14 @@ impl MotionWorld<'_, '_, '_> {
}
self.player.impulse.try_reset();
}
/// `LavaCauldronBlock.entityInside`: CLEAR_FREEZE, LAVA_IGNITE
/// (`Entity.lavaIgnite`, 15 seconds) and then `Entity.lavaHurt` (lava,
/// 4), as ordinary lava does to the player.
fn lava_cauldron_inside(&mut self) -> Result<()> {
self.player.ticks_frozen = 0;
self.player.ignite_for_ticks(300)?;
self.environment_hurt("minecraft:lava", 4.0)
}
/// `WebBlock.entityInside`: stuck at a quarter of the speed, half with
/// the Weaving effect.
fn web_inside(&mut self) {
@@ -2368,6 +2409,124 @@ mod tests {
assert_eq!(player.position.y, 4.0);
}
#[test]
fn a_lava_cauldron_burns_the_player_inside_it() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
let cauldron = assets
.registry
.default_state("minecraft:lava_cauldron")
.unwrap();
scene.set(20, 4, 20, cauldron).unwrap();
let mut tick = |player: &mut PlayerMotion| {
MotionWorld {
entities: &[],
player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.controlled_simulation_tick(Default::default())
.unwrap();
};
// Standing on the cauldron's floor, in its lava.
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.25, 20.5)).unwrap();
player.ticks_frozen = 40;
tick(&mut player);
assert!(player.health.health() < 20.0, "the lava must hurt");
assert!(player.fire_ticks > 0, "the lava must ignite");
assert_eq!(player.ticks_frozen, 0);
// Beside it, nothing.
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
tick(&mut away);
assert_eq!(away.health.health(), 20.0);
assert!(away.fire_ticks <= 0);
}
#[test]
fn standing_in_a_wither_rose_asks_for_its_effect() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
let rose = assets
.registry
.default_state("minecraft:wither_rose")
.unwrap();
scene.set(20, 4, 20, rose).unwrap();
let roses = |player: &PlayerMotion| {
player
.events
.iter()
.filter(|e| matches!(e, MotionEvent::InsideWitherRose))
.count()
};
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.controlled_simulation_tick(Default::default())
.unwrap();
assert_eq!(roses(&player), 1);
// A server's player: its client moved it, the server applies the effect.
player.events.clear();
let from = player.position;
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.server_block_damage(from)
.unwrap();
assert_eq!(roses(&player), 1);
// Two blocks away, nothing.
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
MotionWorld {
entities: &[],
player: &mut away,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.controlled_simulation_tick(Default::default())
.unwrap();
assert_eq!(roses(&away), 0);
}
#[test]
fn server_travel_moves_only_the_velocity() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+7 -1
View File
@@ -686,8 +686,14 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String {
}
}
/// en_us.json from the prepared resources.
/// The language a session resolves server-sent components with: the selected
/// one as `lang` loaded it (`en_us` under it, packs included), so a menu title
/// the server sends as a key follows the language screen. A process with no
/// language set (a dedicated server's log) reads en_us.json.
pub fn load_language(resources: &std::path::Path) -> Language {
if let Some(lang) = crate::lang::current() {
return lang.map().clone();
}
std::fs::read(resources.join("assets/minecraft/lang/en_us.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
+31 -9
View File
@@ -893,6 +893,9 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
ModSettings(usize),
OpenModFolder,
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
@@ -944,6 +947,9 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
Credits,
/// Copy how an agent connects (the AI Agent page).
@@ -1503,15 +1509,26 @@ impl OptionsUi {
} else if installed.dev {
label = label.append(" (Dev)");
}
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
Row::Buttons(
if installed.dev || installed.id.is_empty() {
inactive(widget)
} else {
widget
},
None,
)
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
0.0,
150.0,
Text::literal("Settings"),
Act::ModSettings(i),
);
Row::Buttons(
if dead { inactive(toggle) } else { toggle },
Some(settings),
)
} else {
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
Row::Buttons(if dead { inactive(widget) } else { widget }, None)
}
})
.collect()
}
@@ -2498,6 +2515,10 @@ impl OptionsUi {
s.mods.toggle(i);
events.push(Event::Changed);
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
events.push(Event::ModSettings(i));
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
@@ -2988,6 +3009,7 @@ mod tests {
description: "May show HUD panels.".into(),
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
});
}
let (widgets, ..) = ui.layout(&s, size.0, size.1);
+63 -12
View File
@@ -18,7 +18,10 @@ use horizon_core::{
random::{LegacyRandom, RandomSource},
};
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{collections::HashMap, path::Path};
use std::{
collections::{HashMap, HashSet},
path::Path,
};
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
@@ -196,6 +199,35 @@ impl ParticleType {
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -1147,7 +1179,9 @@ pub struct ParticleEngine {
pub requested: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
names: Vec<String>,
rects: Vec<Option<Rect>>,
atlas: HashMap<String, Rect>,
/// Each named sprite, and whether `SingleQuadParticle.Layer.bySprite` puts
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
@@ -1167,7 +1201,7 @@ pub struct ParticleEngine {
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>) -> Self {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
particles: Vec::new(),
pending: Vec::new(),
@@ -1178,7 +1212,10 @@ impl ParticleEngine {
requested: None,
names: Vec::new(),
rects: Vec::new(),
atlas: atlas.clone(),
atlas: atlas
.iter()
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
.collect(),
sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
@@ -1220,7 +1257,7 @@ impl ParticleEngine {
return i as u32;
}
self.names.push(name.to_owned());
self.rects.push(self.atlas.get(name).copied());
self.rects.push(self.atlas.get(name).map(|&(rect, _)| rect));
(self.names.len() - 1) as u32
}
pub fn len(&self) -> usize {
@@ -3760,9 +3797,18 @@ impl ParticleEngine {
{
continue;
}
let rect = match &p.sprite {
Sprite::Index(i) => self.rects.get(*i as usize).copied().flatten(),
Sprite::Named(name) => self.atlas.get(name).copied(),
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
// layer draws after translucent terrain, so a crumb of stained
// glass cannot hide the glass behind it (`getLayer`).
let (rect, layer) = match &p.sprite {
Sprite::Index(i) => (
self.rects.get(*i as usize).copied().flatten(),
p.ty.translucent_layer(),
),
Sprite::Named(name) => match self.atlas.get(name) {
Some(&(rect, translucent)) => (Some(rect), translucent),
None => (None, false),
},
};
let Some(r) = rect else { continue };
let a = f64::from(partial);
@@ -3867,7 +3913,11 @@ impl ParticleEngine {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
},
color,
light,
});
@@ -3891,7 +3941,7 @@ mod tests {
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
// Destination offset (-15, 7, 15); the unused top byte is ignored.
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0);
@@ -3916,7 +3966,7 @@ mod tests {
#[test]
fn consume_teleport_unpacks_offsets_within_127() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
// TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition
// of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4).
@@ -3941,7 +3991,8 @@ mod tests {
(2, 0, 99_413_408_083_437),
] {
for distant in [false, true] {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
let mut engine =
ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(0, 0, 0);
if distant {
engine.camera = [1000.0; 3];
+34 -1
View File
@@ -488,7 +488,12 @@ pub fn encode_block_entity(
entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer)));
entries.push(("cooking_total_time", Tag::Int(f.cooking_total)));
entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits())));
entries.push(("RecipesUsed", Tag::Compound(Compound::new())));
let used: Compound = f
.recipes_used
.iter()
.map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32)))
.collect();
entries.push(("RecipesUsed", Tag::Compound(used)));
}
BlockEntity::Brewing(b) => {
entries.push(("Items", encode_items(&b.items)));
@@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
if let Some(speed) = float(tag.get("speed_multiplier")) {
f.speed = speed as f32;
}
// A save without it owes nothing: the field is newer than some worlds.
if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") {
for (id, count) in used {
let count = match count {
Tag::Int(n) => *n,
_ => continue,
};
f.recipes_used
.insert(id.to_string_lossy(), count.max(0) as u32);
}
}
}
BlockEntity::Brewing(b) => {
let items = decode_items(tag.get("Items"), 5);
@@ -4276,4 +4292,21 @@ mod tests {
.unwrap_or(0);
assert_eq!(flying_survival, 0);
}
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
/// player nothing after a reload.
#[test]
fn a_furnaces_recipes_used_survive_a_save() {
use crate::containers::BlockEntity;
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
panic!("a furnace");
};
f.recipes_used
.insert("minecraft:cooked_porkchop".to_owned(), 3);
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
panic!("a furnace back");
};
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
}
}
+22 -12
View File
@@ -8,10 +8,12 @@
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
// costs what it did with nearest and repeat only: one sample.
// costs what it did with nearest and repeat only: one sample. Each entry
// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend)
// combines a texture with its sampler, and an override would not remove the
// other sampler's branch.
@group(0) @binding(2) var nearest_sampler: sampler;
@group(0) @binding(4) var linear_sampler: sampler;
override BLUR: bool = false;
override CLAMP: bool = false;
struct Out {
@builtin(position) clip: vec4<f32>,
@@ -32,28 +34,36 @@ struct Out {
out.edges = vec4(rect.xy + half, rect.zw - half);
return out;
}
fn sample(in: Out) -> vec4<f32> {
// The sprite's atlas coordinates of this pixel.
fn atlas_uv(in: Out) -> vec2<f32> {
var f = in.uv;
if !CLAMP {
// Repeat: one period of the sprite.
f = fract(f);
}
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
if CLAMP {
uv = clamp(uv, in.edges.xy, in.edges.zw);
return clamp(uv, in.edges.xy, in.edges.zw);
}
if BLUR {
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
}
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
return uv;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
let c = sample(in);
fn screen(c: vec4<f32>) -> vec4<f32> {
if c.a <= 0.0 { discard; }
return c;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color);
}
@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4<f32> {
return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color);
}
// RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the
// texture colour; the blend state darkens the frame by it.
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
return vec4(sample(in).rgb, 1.0);
let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color;
return vec4(c.rgb, 1.0);
}
@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4<f32> {
let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color;
return vec4(c.rgb, 1.0);
}
+1 -20
View File
@@ -619,26 +619,7 @@ impl TextField {
/// `getWordPosition(dir, from)`: past the next space run, or back to the
/// start of the word.
pub fn word_position(&self, dir: isize, from: usize) -> usize {
let v: Vec<char> = self.value.chars().collect();
let mut at = from;
if dir > 0 {
at = v
.iter()
.skip(at)
.position(|c| *c == ' ')
.map_or(v.len(), |p| p + at);
while at < v.len() && v[at] == ' ' {
at += 1;
}
} else {
while at > 0 && v[at - 1] == ' ' {
at -= 1;
}
while at > 0 && v[at - 1] != ' ' {
at -= 1;
}
}
at
crate::ui::word_position(&self.value, dir, from)
}
/// `keyPressed` of a focused, editable box: `Some(changed)` when handled.
pub fn key(
+7 -1
View File
@@ -2148,8 +2148,13 @@ mod cover_tests {
}
/// Every question streaming asks gets the same answer with the grid.
///
/// A debug build checks every fourth cell of each row, shifted by row and case so the cases still cover every
/// cell's position relative to the areas: the full check took 20 of the ~30 s of the crate's tests.
/// `cargo test --release -p horizon-client --lib the_grid_answers_as_the_scan` checks every cell.
#[test]
fn the_grid_answers_as_the_scan() {
let stride = if cfg!(debug_assertions) { 4 } else { 1 };
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
for case in 0..60 {
@@ -2165,7 +2170,8 @@ mod cover_tests {
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
let span = [4, 30, 120][case % 3] + 40;
for z in -span..=span {
for x in -span..=span {
let first = -span + (z + case as i32).rem_euclid(stride);
for x in (first..=span).step_by(stride as usize) {
for m in 0..=ExtraCover::REACH + 2 {
assert_eq!(
scan.in_extra(x, z, m),
+25
View File
@@ -428,6 +428,15 @@ impl Text {
}
out
}
/// `resolve_legacy` borrowing `self` when no span holds a `§` code (the
/// common case).
pub fn resolved(&self) -> std::borrow::Cow<'_, Text> {
if self.spans.iter().any(|s| s.text.contains('§')) {
std::borrow::Cow::Owned(self.resolve_legacy())
} else {
std::borrow::Cow::Borrowed(self)
}
}
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
pub fn strip_legacy(&self) -> Text {
@@ -695,4 +704,20 @@ mod tests {
assert_eq!(t.spans[1].style.color, Some(GOLD));
assert_eq!(t.slice(1, 2).plain(), "y");
}
#[test]
fn resolved_borrows_clean_text_and_restyles_codes() {
let clean = Text::colored("Steve", GOLD);
assert!(matches!(clean.resolved(), std::borrow::Cow::Borrowed(_)));
// A colour code inside a span clears its decorations (a bold
// "§cSteve" draws "Steve" red, not bold).
let styled = Style {
bold: Some(true),
..Style::default()
};
let styled = Text::styled("§cSteve", styled);
let resolved = styled.resolved();
assert_eq!(resolved.plain(), "Steve");
assert_eq!(resolved.spans[0].style.color, Some(RED));
assert_eq!(resolved.spans[0].style.bold, Some(false));
}
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more