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 Marie 5115bc276b Merge pull request #1650 from socksprox/fix/proxy-transfers
🤖 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 17:26:57 +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 Marie 7bac73094f Merge pull request #1860 from ludovic111/fix/launcher-pack-test-windows
🤖 Agent: merged after verify and a launcher dry run on all three platforms (Windows tests pass); the launcher release follows.
2026-10-25 17:25:11 +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 Marie ef82e97d67 Merge pull request #1652 from socksprox/fix/remote-player-skins
🤖 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 17:24:13 +01:00
Ludovic Marie 1920106445 Merge pull request #1793 from socksprox/launcher-dark-titlebar
🤖 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 17:23:29 +01:00
Ludovic Marie aca93fa902 Merge pull request #1779 from levzzz5154/fix/sprint-fov-stuck
🤖 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 17:23:02 +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
Ludovic Marie e99414e9d3 Merge pull request #1795 from socksprox/t3/fix-remote-close-ghost-grid
🤖 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 17:16:15 +01:00
Ludovic Marie 66a35ab2ea Merge pull request #1865 from ludovic111/fix/website-docker-data
🤖 Agent: merged after verify; image build simulated locally (hub, data, docs/api in dist).
2026-10-25 17:15:31 +01:00
Ludovic MarieandClaude Opus 5.5 d41a3075aa Launcher: a local registry's file:/// URLs open on Windows
file:///C:/x left /C:/x after the scheme, which Windows can't open; the pack tests now
write real file:/// URLs (the index only accepts those), with forward slashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:13:26 +01:00
Ludovic MarieandClaude Opus 5.5 fd154dcbba Website image: copy data/ so the build finds the roadmap
build.mjs reads website/data/roadmap.json since the hub (#1819), but the Dockerfile never
copied data/, so every site deploy since then failed and playhorizon.dev kept the old build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:13:06 +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
Ludovic Marie 7ab91a4d2e Merge pull request #1859 from ludovic111/feat/mod-tool-content
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 17:00:44 +01:00
Ludovic Marie 9f761a3e3b Merge pull request #1810 from ludovic111/docs/api-portal-website
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 16:58:41 +01:00
Ludovic MarieandClaude Opus 5.5 c640561000 Launcher: the registry pack tests build file URLs with forward slashes, so they pass on Windows
A Windows temp path's backslashes went into the test index's JSON as escapes, so the
index failed to parse and the Windows launcher release stopped at its tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:56:26 +01:00
Ludovic Marie d18b7701da Merge pull request #1852 from ludovic111/feat/hub-docs
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:56:07 +01:00
Ludovic Marie 1bf957989a Merge pull request #1851 from ludovic111/feat/hub-forms
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:54:01 +01:00
Ludovic Marie a230411fa3 Merge pull request #1839 from ludovic111/feat/hub-intake
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:51:54 +01:00
Ludovic Marie 26b94efcdf Merge pull request #1838 from ludovic111/feat/hub-votes
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:49:41 +01:00
Ludovic Marie e5214f7f25 Merge pull request #1821 from ludovic111/feat/hub-board
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:48:00 +01:00
Ludovic Marie 2576e80ff2 Merge pull request #1819 from ludovic111/feat/roadmap-hub
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:45:51 +01:00
Ludovic MarieandClaude Opus 5.5 effea2cfd4 Merge feat/hub-board (main's registry pages) into feat/hub-forms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:41:10 +01:00
Ludovic MarieandClaude Opus 5.5 bcee0d02ec Merge feat/roadmap-hub (main's registry pages) into feat/hub-board
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:40:01 +01:00
Ludovic MarieandClaude Opus 5.5 78ab03e554 Merge main into feat/roadmap-hub: the hub beside the registry's pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:39:17 +01:00
Ludovic MarieandClaude Opus 5.5 ba4cf45aac cargo horizon-mod: content mods (new --template content, build packs assets and data, check takes no code)
- `new --template content`: a content mod with one block (Glowing Amethyst,
  copying vanilla's amethyst block) and its block state, model, item model,
  name, loot, recipe and pickaxe tag; `--template code` is the default.
- `build` packs the mod's `assets/` and `data/` (read and checked as the
  game reads them) beside the component; a folder without `Cargo.toml` is a
  content mod and needs no compiler.
- `check` takes a `.hmod` with no `mod.wasm`: "no code: content only,
  nothing to run" instead of the trial run, which refused it, so the
  registry can publish content mods.
- The guide (content mods, make a mod) and docs/mods.md, docs/api/mods.md
  say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:35:58 +01: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
Ludovic MarieandClaude Opus 5.5 a6f951e126 Website: Make a mod, the full guide from the first line to the registry
Six pages under a "Make a mod" group of the docs: set up and see a mod in
the game (the launcher's Game arguments or cargo horizon-mod run), the
manifest and every capability, the hooks and calls with a worked example
(Playtime, built and checked), content mods, testing and check, and
publishing (minisign signing, the submit form, review, updates). /mods,
the submit page, the docs index, Mods and data packs and the Mod API link
to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:22:19 +01:00
Ludovic Marie 1a91727eee Merge pull request #1815 from ludovic111/feat/roadmap
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:17:14 +01:00
Ludovic MarieandClaude Opus 5.5 818b9638ed CONTRIBUTING.md and docs/README.md point to the hub
Where to start is playhorizon.dev/hub: the roadmap, the work board, good first
tasks and the forms; roadmap PRs carry their roadmap:<id> label, ideas go to the
suggestion queue and issues are for bugs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:15:16 +01:00
Ludovic Marie c462eb0b48 Merge pull request #1846 from ludovic111/feat/registry-owner-links
🤖 Agent: merged after verify passed (website + launcher owner links, tests added).
2026-10-25 16:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 c1ffa17c87 Hub: vote for roadmap items, report a bug and suggest something from the page
Each item still to come gets a "Want it" button with its count; the Contribute
tab gets a bug report form (the #horizon-bugs template, which the telemetry
server opens as a GitHub issue and links back) and a suggestion form (to the
private queue Kira relays), and a Contributors list that shows the Discord name
of those who agreed on Kira (/contributors.json). The forms and buttons appear
once the hub's server answers; until then the page points to GitHub and Discord.
roadmap/README.md documents the intake API that Kira shares.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:12 +01:00
Ludovic MarieandClaude Opus 5.5 4a9ca29887 Website: the API portal at /docs/api, with Markdown tables
The repository's docs/api/ (the portal, its surface pages and the generated mod and
server references) is built into the docs section: /docs/api is its README and
/docs/api/<page> each page, in two nav groups, "Build on Horizon" and "API reference".
The pages are read from the checkout or from main on GitHub, like the README, so the
site never holds a copy. Links between portal pages become the site's (and the build's
link check covers them); other relative links go to the file on GitHub.

The renderer gains tables: the "Ways in" table, the references and the other tables
on the site (apis, performance) showed as raw pipes. A link whose label is a code span
no longer throws.

Caddy and the dev server route /docs/api/<page> and redirect /docs/api/ to /docs/api;
Railway rebuilds when docs/api/ changes. /docs/mods links the Mod API, its reference
and its ways in; the summary page is now "APIs at a glance".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:03 +01:00
Ludovic MarieandClaude Opus 5.5 bac51e3fba Telemetry server: the hub's bug reports and suggestions
POST /hub/report opens a GitHub issue at once (labels bug and from-website, the
every template field required, people's text made inert (no mentions, issue
references, HTML or closing keywords) and the same report twice answered with the
first issue. If GitHub fails it answers 202 and the upkeep tries again for a week.
Off (503) without HORIZON_TELEMETRY_GITHUB_TOKEN.

POST /hub/suggest queues a suggestion; GET /hub/suggestions?after=<id> hands the
queue to Kira with the HORIZON_TELEMETRY_HUB_TOKEN bearer (404 when unset, 401 for
a wrong token). Nothing is published.

Per-address limits (20 an hour, bursts of 2) on keyed hashes of the address, daily
caps from everyone (30 reports, 200 suggestions), a honeypot field, entries kept
90 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:10:18 +01:00
Ludovic MarieandClaude Opus 5.5 91148cdf74 Telemetry server: hub votes allow 600 an hour per address (bursts of 60)
A visitor voting down the roadmap, or a school behind one address, ran out of
the 12-request burst. Votes are cheap; the item cap and the voter id still bound them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:09:48 +01: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
Ludovic MarieandClaude Opus 5.5 0eb475152e Registry: show who publishes a project, linked to their GitHub profile
The index already carries each project's owners (the GitHub accounts the
registry checked at submission), but neither the website nor the launcher
showed them. The project page now says "Published by @login" with the
avatar and a link to the profile; the launcher adds an @login link per
owner. Only strings that are valid GitHub logins become links.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:07:46 +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
Ludovic MarieandClaude Opus 5.5 e458e37249 Hub: journal.json in the flat format Kira reads, at /data/journal.json
One entry per merged pull request (with its roadmap item), game or launcher
release, shipped roadmap item (post and announce) and weekly summary, newest
first, ids stable forever: the feed kira's src/journal.js posts to #updates and
X. The journal's item links are hub#<id>; the board's and the journal's ids are
reserved too, and the page-wide unique-id test covers every tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:02:36 +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 c04b7afdb9 Hub: the Kanban and the Journal tabs, and /hub/journal.json
Kanban: every open issue and pull request and what closed in the last seven days, placed in To do,
In progress, In review and Done by the rules of Kira's work board, each card with its roadmap item
(its roadmap:<id> label or the item linking it) and a filter by item. Journal: every release with its
changes grouped by roadmap item, the merges no release has yet, and a weekly summary from
roadmap/journal/<year>-<week>.md. The same journal is /hub/journal.json, for Kira's #updates and posts
elsewhere: each entry names the roadmap items it moved and shipped, with their 240-character text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:00:03 +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
Ludovic MarieandClaude Opus 5.5 68234ac407 Hub: the roadmap at /data/roadmap.json and items at hub#<id>, as Kira reads them
Kira (kira #10-#14) fetches https://playhorizon.dev/data/roadmap.json and links
items as <hub>#<id>: the JSON moves there (readable from any origin) with the
announce flag, and each item's anchor is its id. Ids the page uses itself (tabs,
sections, headings) are refused by the check; a test keeps every id of the page
unique, which caught the overview's headings sharing ids with the roadmap's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:59:31 +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
Ludovic MarieandClaude Opus 5.5 e936e412ba Website: the hub (/hub) with the roadmap, a Simple / Technical switch and live states
playhorizon.dev/hub is the public entry point to what is happening on Horizon. Overview: where
Horizon is, the 3 to 5 things in focus, what is next, what shipped last, how to help. Roadmap: Now /
Next / Later / Help wanted / Shipped, filtered by area, every item with its issues and pull requests
in their GitHub state (baked at build, refreshed in the browser). Contribute: good first tasks,
claiming, connecting an agent to Kira, the rules. A Simple / Technical switch swaps every item's
plain and dev text and is remembered. /roadmap redirects to the Roadmap tab.

tools/roadmap.py build now also writes website/data/roadmap.json, which the site reads and serves at
/hub/roadmap.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:46 +01:00
Ludovic MarieandClaude Opus 5.5 a95be8a7f3 Roadmap: announce = true marks items Kira also posts on X when they ship
Kira's X poster (kira #12) posts shipped items flagged announce in journal.json.
Set on the five whose release is news: modpack world generation, the mod
registry, Discord Rich Presence, Microsoft sign-in and CurseForge. Flags must be
booleans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:34 +01:00
Ludovic Marie bd591f9857 Merge pull request #1814 from ludovic111/fix/mod-registry-pr-marker
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:54:52 +01:00
Ludovic MarieandClaude Opus 5.5 5d40d6a33e Telemetry server: the hub's votes (POST /hub/vote, GET /hub/votes)
A thumbs-up on a roadmap item at playhorizon.dev/hub: one per browser (a random
id the page keeps), taken back with "up": false, counts cached a minute. Item ids
are checked for shape and capped at 1000 distinct items. Rate limits key their
buckets by a keyed hash of the address (key drawn at start, in memory); no address
is stored. CORS preflight for the website's JSON posts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:54:39 +01:00
Ludovic Marie 930a18c912 Merge pull request #1640 from ludovic111/feat/launcher-horizon-modpacks
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:53:18 +01: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
Ludovic Marie 1c67541379 Merge pull request #1638 from ludovic111/feat/launcher-horizon-tab
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:51:16 +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
Ludovic Marie e28b453762 Merge pull request #1637 from ludovic111/feat/launcher-horizon-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:49:13 +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
Ludovic Marie 6a2512bfe7 Merge pull request #1636 from ludovic111/feat/launcher-horizon-source
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:47:09 +01:00
Ludovic Marie bd7bb1dfc6 Merge pull request #1822 from ludovic111/feat/native-shaders-sdk
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:46:54 +01:00
Ludovic Marie e785362aa7 Merge pull request #1635 from ludovic111/feat/website-mods-submit
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:45:17 +01: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
Ludovic Marie 6c4d1d6cbb Merge pull request #1596 from ludovic111/feat/website-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:43:14 +01:00
Ludovic Marie 2dbdcda89c Merge pull request #1820 from ludovic111/feat/native-shaders-check
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:41:39 +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
Ludovic MarieandClaude Opus 5.5 49f62d02cb Horizon shader packs: horizon-shader new/check/pack, templates, an example pack and the guide
The SDK's tool, in horizon-shader-api: `horizon-shader new <dir> --template
post|sky|material` makes a pack that passes the check, `check <pack>...
[--json]` runs the check the game, the launcher and the registry run and
prints each stage's cost and the pack's SHA-256 or every problem at its
file:line:column, and `pack <dir>` checks then writes the reproducible
.hshader. Golden Hour, the example pack, warms the sky around a low sun and
adds a soft glow and a colour grade, with five options. docs/shaders.md is the
authors' guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:38:23 +01:00
Ludovic Marie 220f057647 Merge pull request #1818 from ludovic111/feat/native-shaders-stages
🤖 Agent: merged after verify passed (new crate horizon-shader-api + generated reference with drift test).
2026-10-25 15:37:32 +01:00
Ludovic Marie f94b05032b Merge pull request #1817 from ludovic111/feat/native-shaders-format
🤖 Agent: merged after verify passed (new crate horizon-shader-api only).
2026-10-25 15:34:25 +01:00
Ludovic MarieandClaude Opus 5.5 923fdee42e Horizon shader packs: the check every pack passes before it runs
horizon_shader_api::check builds each stage as the renderer does and judges
it the same way wherever it runs (the game, the launcher, the registry's CI,
the coming horizon-shader tool): naga parses and validates it; the stage
function must have its signature; the pack's files declare no bindings, no
entry points and no horizon_ names; each override is an option of its type or
has a default, and each option is used; a stage stays within 20 000
expressions and 64 texture reads; and every entry point translates to SPIR-V,
Metal and HLSL with the options at their defaults, minimums and maximums.
Problems carry the pack's own file, line and column. Pack::sha256 gives the
reproducible package's hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:34:07 +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
Ludovic MarieandClaude Opus 5.5 6b97107dc7 Transfers: handshake with the TRANSFER intent, answer login cookie requests
Vanilla 26.3 reconnects after ClientboundTransferPacket with a TransferState,
so Connection.initiateServerboundPlayConnection sends ClientIntent.TRANSFER (3):
a target with accepts-transfers=false refuses the player, and the target can
ask for the carried cookies during login
(ClientHandshakePacketListenerImpl.handleRequestCookie). Both now match.

A transfer from single player closes the world instead of connecting
elsewhere (handleTransfer: "Cannot transfer to server from singleplayer").

Adds a unit test for the play-state store/request round trip and the
Transfer event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:29:59 +01: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 608d1a70a7 Horizon shader packs: the stages of API 0.1 and their generated reference
Three stages, each a WGSL function with a fixed signature that Horizon calls
inside its own pass: sky (fn sky(input: SkyInput) -> vec4<f32>), material
(fn material(input: MaterialInput) -> Surface) and post (fn post(input:
PostInput) -> vec4<f32>). A stage's WGSL is a generated prelude (the frame
uniform, the stage's structs, its resources in bind group 3, the post
helpers), the pack's shared files, its own file, then the entry points; each
part is located so errors point at the pack's file and line.

The frame is the Rust struct the renderer will upload; its WGSL, its offsets
and docs/api/shaders-reference.md are generated from it, and tests check that
the preludes compile, that naga's layout of the WGSL is the Rust layout, that
the sample pack's stages compile, and that the reference is up to date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:27:43 +01:00
Ludovic MarieandClaude Opus 5.5 98c8db50a3 Horizon shader packs: horizon-shaders.toml (schema 1) and the .hshader package
A new crate, horizon-shader-api, reads and checks Horizon's own WGSL shader
packs (docs/api/shaders.md), so the game, the launcher and the registry judge
a pack the same way. This first part is the format: the manifest (id, name,
version, the api line, shared files, the sky, material and post stages,
options typed for WGSL override constants, a GPU time budget) and the package
(a folder or a .hshader zip, built reproducibly so its SHA-256 depends only on
its files). The stages' WGSL is checked in the next parts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:18:03 +01:00
Ludovic MarieandClaude Opus 5.5 6d418fdb3e Docs: publish a mod through the registry, and how horizon-mods runs
docs/mods.md gets "Publish your mod" (the form, the review, updates) and
its Distribution section points at the registry instead of the plan for
it. docs/api/mod-index.md says how horizon-mods runs: its checks, merging,
Pages, the weekly cleanup, and that removing an entry needs an admin merge
(takedowns go in revoked.toml). The service's README says where it runs on
Railway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:17:30 +01:00
Ludovic MarieandClaude Opus 5.5 98bebd26bd Launcher: one place reads the pinned appearance (theme::pinned_dark)
The title bar and the theme both resolved HORIZON_LAUNCHER_APPEARANCE and the
Settings choice; titlebar::apply_appearance now uses the theme's helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 22c3f2bd0f Roadmap: roadmap/roadmap.toml, ROADMAP.md generated from it, and its checks
One source of truth for what is being done, what comes next and where to help: roadmap/roadmap.toml,
each item said twice (plain words for players, the technical picture for contributors), Now / Next /
Later / Help wanted / Shipped, no dates. tools/roadmap.py builds ROADMAP.md from it, checks its rules
offline (CI, through tests/test_roadmap.py) and against GitHub (links resolve, no stale Now item),
ships items whose pull requests all went out in a release (sync), and keeps a roadmap:<id> label per
item (labels). README links it; roadmap/README.md is the maintenance guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:13:51 +01: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
Ludovic MarieandClaude Opus 5.5 ceff5a2110 Launcher: installing a taken-down registry mod says it was taken down, and why
It said the mod had no version for 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:06:34 +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 93d05591c2 horizon-mod-registry: a submission's pull request says it came through the form
The form opens pull requests with the registry's token, which is Ludovic's
account, so the body now starts "🤖 Submitted through playhorizon.dev by
@login" and says neither Ludovic nor a reviewer wrote it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:04:04 +01:00
Ludovic MarieandClaude Opus 5.5 5680da9834 Merge feat/launcher-horizon-tab (main merged) into feat/launcher-horizon-modpacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 41e68aeb76 Merge feat/launcher-horizon-mods (main merged) into feat/launcher-horizon-tab
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 28ad41eb22 Merge feat/launcher-horizon-source (main merged) into feat/launcher-horizon-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 de195755f7 Merge origin/main into feat/launcher-horizon-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:20 +01:00
Ludovic Marie 587af1e345 Merge feat/website-mods (main merged) into feat/website-mods-submit
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	website/build.mjs
2026-10-25 14:57:08 +01:00
Ludovic MarieandClaude Opus 5.5 65fa108cba Merge origin/main into feat/website-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:56:52 +01: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
socksproxandDevin 56560b467a Multiplayer: answer cookie requests, store cookies, follow transfer packets
ClientboundCookieRequestPacket in play state decoded as Unknown and was
dropped; StoreCookie and Transfer likewise, and the configuration-phase
CookieRequest was always answered empty. Proxies (BungeeCord, Velocity)
store and read cookies around a server switch and send Transfer to move
a player to another address — both ended in a kick.

Play-state cookie_request/store_cookie/transfer now have packet types,
cookie requests answer from the stored cookies in both configuration and
play (ClientCommonPacketListenerImpl.handleRequestCookie), stored cookies
land in the session's map (handleStoreCookie), and a transfer reconnects
the game to the target address carrying them (handleTransfer →
ConnectScreen.startConnecting with TransferState).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:51:32 +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 fb1142cd71 Skins: keep vanilla's whole-payload rejection, drop the dead name fallback
authlib 10.0.77 MinecraftServicesSessionService.unpackTextures returns
MinecraftProfileTextures.EMPTY when any texture has no URL or a URL outside
the allowed domains, so skins::unpack goes back to main's behaviour.

remote_player_names: r.players and r.tab_players are filled from the same
ADD_PLAYER entries and emptied by the same PlayerInfoRemove, so the
tab-list fallback could never apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:31:41 +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
Ludovic MarieandClaude Opus 5.5 e069750ca1 Merge origin/main into fix/sprint-fov-stuck
Keeps main's crystals check and this branch's sprint-fov check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:28 +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 Marie 6a065fa489 Merge pull request #1788 from ludovic111/feat/discord-presence
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux.
2026-10-25 14:24:58 +01:00
Ludovic Marie e6dbc3b39f Merge pull request #1787 from ludovic111/feat/discord-presence-ipc
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux (see #1788).
2026-10-25 14:22:55 +01:00
Ludovic Marie dbc66a5a41 Merge pull request #1697 from ludovic111/feat/shader-packs-terrain
Shader packs, clean room 13: loading the terrain program
2026-10-25 14:21:33 +01:00
Ludovic Marie d14b100a97 Merge pull request #1670 from ludovic111/feat/shader-packs-atlas
Shader packs, clean room 12: the block atlas packs sample
2026-10-25 14:21:21 +01:00
Ludovic Marie 9ed21bf436 Merge pull request #1708 from rdbtCVS/t3/7c6098f0
Website: unify navigation across Home, Releases, Benchmarks and Docs
2026-10-25 14:21:12 +01:00
Ludovic Marie ab2006a230 Merge pull request #1669 from ludovic111/feat/shader-packs-gbuffers-translate
Shader packs, clean room 11: translating gbuffers programs
2026-10-25 14:21:07 +01:00
Ludovic Marie b61ef8f9be Merge pull request #1668 from ludovic111/feat/shader-packs-post-perf
Shader packs, clean room: fewer passes and loads in post-processing
2026-10-25 14:20:56 +01:00
Ludovic Marie bd4236f3dd Merge pull request #1656 from ludovic111/feat/shader-packs-post
Shader packs, clean room 10: post-processing over Horizon's world
2026-10-25 14:20:44 +01:00
Ludovic Marie 8ec23a2606 Merge pull request #1655 from ludovic111/feat/shader-packs-uniforms
Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
2026-10-25 14:20:32 +01:00
Ludovic Marie 1b6a55e47d Merge pull request #1654 from ludovic111/feat/shader-packs-post-programs
Shader packs, clean room 8: a dimension's post-processing programs
2026-10-25 14:20:19 +01:00
Ludovic Marie c143be7fd0 Merge pull request #1653 from ludovic111/feat/shader-packs-post-load
Shader packs, clean room 7: buffer settings and output lists from stage files
2026-10-25 14:20:05 +01:00
Ludovic Marie da8ae88eee Merge pull request #1601 from ludovic111/feat/shader-packs-translate
Shader packs, clean room 6: composite-style programs to naga
2026-10-25 14:19:52 +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 Marie 3a419877b0 Merge pull request #1752 from rdbtCVS/t3/fix-end-crystal-attack
Fix End crystal attacks from connected clients
2026-10-25 14:11:52 +01:00
Ludovic Marie 5fefa6ce42 Merge pull request #1689 from rdbtCVS/t3/d001fd2c
Slow Falling clears the fall distance so landing does not hurt
2026-10-25 14:11:43 +01:00
Ludovic Marie 6e4cb6210e Merge pull request #1687 from rdbtCVS/fix/creative-hotbar-hover
Creative inventory: highlight hovered hotbar slots
2026-10-25 14:11:33 +01:00
Ludovic Marie 985ec67b83 Merge pull request #1685 from sdraeger/fix/nbt-fireballs
Fireballs from NBT fly: /summon, chunk loads, selectors and /data get
2026-10-25 14:11:23 +01:00
Ludovic Marie ca05c89c6e Merge pull request #1676 from sdraeger/fix/ctl-screenshot-spaces
horizon-ctl screenshot: paths with spaces
2026-10-25 14:11:15 +01:00
Ludovic Marie 18c74b438c Merge pull request #1675 from sdraeger/fix/tp-mob-pitch
/tp: keep the pitch for mobs
2026-10-25 14:11:05 +01:00
Ludovic Marie ff7cfe0f10 Merge pull request #1695 from Domanffe/chore/deslop
Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
2026-10-25 14:10:57 +01:00
Ludovic Marie 250c148e78 Merge pull request #1673 from Domanffe/fix/title-menu-labels
Title: the main menu labels follow the game language
2026-10-25 14:10:49 +01:00
Ludovic Marie 40fcc91eff Merge pull request #1682 from Domanffe/feat/horizon-lang-namespace
Options: Horizon's own captions read a bundled language namespace
2026-10-25 14:10:41 +01:00
Ludovic Marie 8b3d4a069c Merge pull request #1686 from Domanffe/fix/launcher-remove-retry
Launcher: a version removal retries a locked file and leaves the UI thread
2026-10-25 14:10:32 +01:00
Ludovic Marie a839062259 Merge pull request #1694 from Domanffe/fix/swallowed-save-errors
Save: the write paths report a failure instead of dropping it
2026-10-25 14:10:24 +01:00
Ludovic Marie 6194463454 Merge pull request #1693 from Domanffe/fix/pack-overlay-windows
Packs: an untouched vanilla directory is mirrored on Windows too
2026-10-25 14:10:15 +01:00
Ludovic Marie a5f574b77e Merge pull request #1707 from Domanffe/chore/clippy-green
Clippy: clear the two warnings the whole tree fails on
2026-10-25 14:10:08 +01:00
Ludovic Marie c4573fb561 Merge pull request #1674 from sdraeger/fix/client-mods-server-thread
Client mods: tick, commands and chat on the server thread
2026-10-25 14:09:59 +01:00
Ludovic Marie 57fc1570b2 Merge pull request #1681 from socksprox/fix/join-loading-status
Multiplayer: drop the player name from the join loading line
2026-10-25 14:09:51 +01:00
Ludovic Marie 628e230217 Merge main (the save_root lint fix, #1789) 2026-10-25 14:08:29 +01:00
Ludovic Marie 6926c76492 Merge pull request #1590 from socksprox/feat/boss-overlay 2026-10-25 14:02:40 +01:00
Ludovic MarieandClaude Opus 5.5 47d1b060b2 Discord presence: test where the game looks for Discord on macOS, Linux, Flatpak and Snap
sockets() reads the environment through a function, so a unit test can
check the paths a real Discord listens on for each platform ($TMPDIR on
macOS, $XDG_RUNTIME_DIR and its Flatpak and Snap subfolders on Linux,
named pipes on Windows). Offscreen e2e runs can't cover this: an automated
run only talks to a fake Discord named by HORIZON_DISCORD_IPC_DIR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:57:19 +01:00
socksproxandDevin 87b3c9aa38 Drop the stale craft grid when a remote menu closes
The server's AbstractContainerMenu.removed gives the grid and the
carried stack back to the inventory and syncs those slots, but the
client kept its copies: a crafting table closed with items inside left
ghost stacks in the inventory's 2x2 grid that could not be picked up.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:51:12 +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
socksproxandDevin 3740de5116 Launcher: pin the macOS title bar to the chosen appearance
The launcher's Appearance setting darkened the page but not the window's native chrome: NSWindow keeps the system appearance, so a launcher set to Dark still drew a light title bar on a light desktop. apply_appearance (titlebar.rs) pins the NSWindow's appearance to the choice at startup and when the setting changes; System clears the pin so the chrome follows the computer again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:40:07 +01:00
Ludovic Marie d2c96af142 Merge pull request #1789 from ludovic111/fix/main-unused-save-root
Fix main's build: unused save_root in open_world
2026-10-25 13:32:01 +01:00
socksproxandDevin 0bc85843ce Client: remote players keep their skins instead of falling back
skins::unpack discarded a profile's whole textures payload when any one
entry lacked "url" or pointed outside the texture services' domains, so
a player with e.g. an unusual cape lost their skin too; it now drops only
the offending texture. The 32 skin atlas slots evicted past players back
to defaults on busy servers — now 128. A dimension reset (join game or
respawn onto another world) now also clears the entity-id→profile map so
a re-used entity id can't pin the wrong skin or name to a player, and the
nametag join falls back to the player-list entry's name.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:27:54 +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
Ludovic MarieandClaude Opus 5.5 d33f84ef73 Fix main's build: drop the unused save_root left in open_world
#1665 moved the save path into finish_open_world and #1666's merge kept the
old line, so clippy -D warnings fails on main (unused variable) since 7d0ae5658.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:24:35 +01:00
Ludovic Marie ad63f2e960 Merge pull request #1659 from ludovic111/feat/mod-registry-server 2026-10-25 12:46:00 +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 Marie 7d0ae56589 Merge pull request #1666 from rdbtCVS/t3/explicit-execution-roles 2026-10-25 12:04:39 +01:00
Ludovic Marie e3de4a4604 Merge pull request #1665 from rdbtCVS/t3/separate-world-entry-loading 2026-10-25 12:02:41 +01:00
Ludovic Marie 9ae86086dc Merge pull request #1664 from Domanffe/fix/data-get-live-passengers 2026-10-25 12:00:55 +01:00
levzzzandCodex 57ee081038 Keep sprint state independent of held sprint keys
Let PlayerCommand control the server sprint flag, matching vanilla, so releasing Ctrl cannot leave the client's speed modifier and FOV stuck. Cover the packet sequence and FOV recovery in regression tests.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 15:56:53 +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 3063d87199 docs: Discord Rich Presence is live with the Horizon application
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:53 +01:00
Ludovic MarieandClaude Opus 5.5 15e46114a6 Discord Rich Presence: show what the player does, with a switch in Horizon Settings
Each frame the game hands the presence thread its status (menus; single
player, multiplayer or hosting a world with its party size; the
dimension; the game mode), and only a change wakes it. The switch
"Discord Rich Presence" (on by default, discordPresence in
horizon-extras.txt) sits beside Controller Settings. Dedicated servers
never start it. tools/discord_presence_e2e.py runs real games against a
fake Discord: title, world, Nether and quit on the server thread and
in-frame, a LAN host (both paths) and guest, the switch, and a Discord
started after the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:44 +01:00
Ludovic Marie fbfec6435f Merge pull request #1588 from socksprox/feat/server-resource-packs 2026-10-25 11:49:29 +01:00
Ludovic MarieandClaude Opus 5.5 e49de342ed Discord Rich Presence: the Horizon application's id
Discord's "Horizon" application (1563857609738690560, public) has the
horizon logo as its large image; no game-mode images yet. The feature is
live: only HORIZON_NO_DISCORD=1, the switch and automated runs keep it
quiet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:47:13 +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 Marie 7a3578b7d6 Merge pull request #1587 from levzzz5154/fix/predict-block-placement
Predict block placement on the client, so fast bridging stops losing clicks
2026-10-25 11:32:09 +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 604ec7e2e4 Discord Rich Presence: the IPC client and its thread (inert until wired)
discord_presence.rs speaks Discord's local IPC itself (discord-ipc-N
socket or named pipe; opcode + length + JSON frames; HANDSHAKE, then
SET_ACTIVITY with the pid) from a thread of its own. The game hands it a
status; the thread connects lazily, retries every 15 s, keeps within 5
updates per 20 s, refreshes every minute and clears the activity on
quit. Inert while DISCORD_APP_ID is the placeholder, with
HORIZON_NO_DISCORD=1, and for automated runs unless
HORIZON_DISCORD_IPC_DIR points at a test's fake Discord; lab.game_env
sets HORIZON_NO_DISCORD=1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:04:38 +01:00
Ludovic Marie 21ff80eb8c Merge pull request #1775 from ludovic111/docs/benchmark-your-computer
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-25 11:03:40 +01:00
Ludovic MarieandClaude Opus 5.5 f3213aa158 Docs: how players benchmark their computer and share the report
The Performance page now gives the launcher command (horizon-launcher play --arg=--benchmark
--arg=standard), where the report lands on each system, what it holds, and the two ways to
share it: horizon-launcher data benchmark, or the Discord.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:02:06 +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
Ludovic MarieandClaude Opus 5.5 71f3c23dd9 Merge main into the launcher removal branch
Main moved the removal to a thread in #1715 (Msg::Removed, the removing
row, the selection), which is this branch's UI half: main's version is
kept. What stays is backend::remove's retry on a file Windows still
holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:38:45 +01:00
Ludovic Marie 574d54e6e0 Merge pull request #1747 from ludovic111/perf/mesh-model-lookup 2026-10-25 09:38:10 +01:00
Ludovic MarieandClaude Opus 5.5 c74490a003 Multiplayer: the join line reads through lang with en_us's own fallback
lang::get_or, as the title screen's labels do; en_us 26.3 has
multiplayer.downloadingTerrain = "Loading terrain...".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:56 +01:00
Ludovic MarieandClaude Opus 5.5 d6cc631735 Merge main into the End crystal attack branch
Conflict in tools/lan_e2e.py's check list: main added shulker_bullets
beside crystals; both kept, crystals' entry aligned with the others.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 0d18ff7ca7 Fall distance: Levitation resets it too, as LivingEntity.aiStep does
aiStep calls resetFallDistance under either Slow Falling or Levitation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:54 +01:00
Ludovic MarieandClaude Opus 5.5 b7fcd446b6 Fireballs: LeftOwner and HasBeenShot are saved as Projectile writes them
Projectile.addAdditionalSaveData writes LeftOwner only when it is set and
HasBeenShot always (true from the first tick on); fireball_to_nbt wrote
LeftOwner:0b and no HasBeenShot. Same shape as arrow_to_nbt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:32 +01:00
Ludovic MarieandClaude Opus 5.5 f23db61ecf /tp: a mob's head turns and its old rotation follows, as teleportSetPosition does
Entity.teleportSetPosition calls setYRot, setYHeadRot and setXRot, then
setOldPosAndRot: the head faces the new yaw and yRotO/xRotO match, so a
teleported mob doesn't sweep from its old pitch on the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:46 +01:00
Ludovic MarieandClaude Opus 5.5 b470bec351 Cleanup: one lock helper in horizon-bots, the predicate helper beside its trait
The twenty unwrap_or_else(|e| e.into_inner()) calls become one lock()
helper, so the bots read as they did with the same poison tolerance.
bound_property moves from worldgen.rs (between DecodeError's impls) to
block_predicate.rs, next to PredicateBlockResolver, whose
predicate_property it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:25 +01:00
Ludovic MarieandClaude Opus 5.5 53e1191480 Title: the Friends icon reads vanilla's gui.friends.open
FriendsButton's message is gui.friends.open, so the title screen needs
no Horizon key for it. The fallbacks are en_us's own text for the
three icons ("Language...", "Accessibility Settings..."), as the
other labels' are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:33 +01:00
Ludovic MarieandClaude Opus 5.5 f28722fb15 Lang: drop horizon.menu.friends, vanilla has gui.friends.open
TitleScreen's Friends button is vanilla's FriendsButton, whose message
is gui.friends.open ("Friends" in en_us, translated in every language
the prepared resources carry), so Horizon needs no key of its own for
it. Bundled now sits after the trait it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:24 +01:00
Ludovic MarieandClaude Opus 5.5 a61c7faa23 Launcher: a version being removed shows Removing… instead of its buttons
With the removal off the UI thread, the row kept its Use and Remove
buttons, so a second Remove or Use could race the deletion. The retry
loop also reads as one match now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 c0bf5cf954 Dedicated: the list saves log vanilla's warnings
DedicatedPlayerList logs "Failed to save user banlist: " (and ip
banlist, operators list, white-list) when a startup save fails; the
shared closure printed "Failed to load user banlist write". The closure
now takes the verb, so loads and saves read as vanilla's lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 fb1f421117 Clippy: take the decorated region by value, and gate the unix-only test helper
receive_decorated moved the nine protos out of the box anyway (the
array iterator holds them inline), so taking [Proto; 9] and unboxing at
the three call sites costs the same and leaves boxed_local nothing to
flag on any target. The #[expect] failed on Linux, where the lint does
not fire, which is why verify was red.

scratch is only called from the #[cfg(unix)] tests, so it carries the
same cfg as installed() next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:32:55 +01:00
Ludovic Marie 0c5d229ad0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Cargo.toml
2026-10-25 09:13:06 +01:00
Ludovic Marie 327a6cafc7 Merge pull request #1736 from ludovic111/feat/yung-jigsaw-draws 2026-10-25 09:08:04 +01:00
Ludovic Marie 6a32ee68d4 Merge pull request #1725 from RVZO6/feature/text-display-rendering
Client: render server text displays and lobby labels
2026-10-25 09:07:59 +01:00
Ludovic Marie 0e73edceff Merge pull request #1718 from RVZO6/feature/text-display-state
Client: model text-display metadata against the Java wire codec
2026-10-25 09:07:02 +01:00
Ludovic Marie f7bd3e381b Merge pull request #1729 from RVZO6/fix/stop-sound-packet
Client: honor server stop-sound event and source filters
2026-10-25 09:06:02 +01:00
Ludovic MarieandClaude Sonnet 5.5 ea0905e8a2 lan_e2e: no blank lines inside the docstring after the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:58:25 +01:00
Ludovic Marie b149a0ec3f Merge main into fix/stop-sound-packet: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:51:09 +01:00
Ludovic Marie 81b1be055c Merge pull request #1739 from rdbtCVS/t3/fix-hero-trading-discount
Trading: keep Hero of the Village discounts for server players
2026-10-25 08:50:37 +01:00
Ludovic Marie ebf76f4c03 Merge main into t3/fix-hero-trading-discount: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:41:23 +01:00
Ludovic Marie fa093994ae Merge pull request #1733 from rdbtCVS/t3/d8cfe14b
/tp: dismount players before teleporting
2026-10-25 08:40:30 +01:00
Ludovic Marie 273c456adc Merge pull request #1717 from ludovic111/fix/jigsaw-oracle-layouts 2026-10-25 08:34:01 +01:00
Ludovic Marie d30be3d994 Merge main into t3/d8cfe14b: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:32:49 +01:00
Ludovic Marie c1e90297e2 Merge pull request #1728 from rdbtCVS/t3/7a98935a
Server thread: remove forgotten recipes from the client's book
2026-10-25 08:32:26 +01:00
Ludovic Marie 9dfdff9d62 Merge main into t3/7a98935a: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:25:09 +01:00
Ludovic Marie e56acaa7a9 Merge pull request #1727 from rdbtCVS/t3/9148b77e
Fix invisible shulker bullets on server clients
2026-10-25 08:24:27 +01:00
Ludovic Marie 65425da01d Merge pull request #1633 from some-du6e/fix/remote-trims 2026-10-25 08:23:48 +01:00
Ludovic Marie 6b88291009 Merge pull request #1631 from some-du6e/fix/item-components 2026-10-25 08:21:58 +01:00
Ludovic Marie e29a71e5e0 Merge pull request #1632 from some-du6e/fix/advancement-text 2026-10-25 08:20:14 +01:00
Ludovic Marie 80eafe84e9 Merge main into t3/9148b77e: keep both sides' appended checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-10-25 08:16:44 +01:00
Ludovic Marie 6017830623 Merge pull request #1719 from rdbtCVS/t3/13ed5f77
Fix crops remaining after server-side farmland trampling
2026-10-25 08:16:06 +01:00
Ludovic MarieandClaude Sonnet 5.5 fb34815ce1 Merge main into server-farmland-trampling: keep both appended lan_e2e checks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 08:07:28 +01:00
Ludovic Marie f49e3a2d24 Merge pull request #1726 from rdbtCVS/t3/9a51354a
Fix offhand eating and drinking cancellation
2026-10-25 08:07:06 +01:00
Ludovic Marie cb0865e242 Merge pull request #1724 from rdbtCVS/t3/19c12e83
Fix spectator collision after dimension travel
2026-10-25 08:07:03 +01:00
Ludovic Marie a27322f250 Merge pull request #1716 from rdbtCVS/t3/395312eb
Fix double-chest inventory and hotbar clicks
2026-10-25 08:05:50 +01:00
Ludovic Marie ed3243ca62 Merge pull request #1730 from rdbtCVS/fix/launcher-java-pairing
Launcher: use one compatible JDK for resource preparation
2026-10-25 08:05:45 +01:00
Ludovic Marie f59bb8b8d5 Merge pull request #1738 from rdbtCVS/fix/pre-push-ref-history
Scope pre-push history checks to pushed refs
2026-10-25 07:54:48 +01:00
Ludovic Marie 00705d8431 Merge pull request #1630 from some-du6e/fix/ipv4-first 2026-10-25 07:10:05 +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
Ludovic Marie 0564ab0bcd Merge pull request #1748 from ludovic111/fix/server-player-targets-barrier
server_player_targets harness: end each step by the server's clock, not 2.5 s
2026-10-25 06:39:50 +01:00
rdbtCVSandOpenCode GPT-6.1 9409d2c19b Multiplayer: allow punching End crystals
Include End crystals in attack-only object hit-testing using their network entity IDs. Add a real-mouse LAN regression for survival and creative, reach, block occlusion, passing item use, and explosion damage.

Co-Authored-By: OpenCode GPT-6.1 <[email protected]>
2026-10-25 05:38:00 +00:00
Ludovic Marie eb1fdf5fb4 Merge pull request #1745 from ludovic111/fix/command-exec-random-count
command_exec harness: start the script without random sequences rolled before the setup
2026-10-25 06:35:31 +01:00
Ludovic Marie 2ee008f8fa Merge pull request #1646 from ludovic111/feat/mod-registry-tools 2026-10-25 06:34:00 +01:00
Ludovic Marie 8a0303ed62 Merge pull request #1642 from ludovic111/feat/mod-check 2026-10-25 06:32:11 +01:00
Ludovic MarieandClaude Opus 5.5 b15e46c3af lan_e2e stopsound: each target's client gets the stop (status counts stops)
Sounds can't be observed playing without an audio device, so the control
status counts each stopped selection, as it counts the sounds heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:26:45 +01:00
Ludovic Marie e988b9b833 Merge pull request #1641 from ludovic111/perf/face-cull-cr1 2026-10-25 06:22:47 +01:00
Ludovic MarieandClaude Opus 5.5 504cb4f74c Text displays: lines split once per render state, as vanilla caches them
TextDisplayRenderer.splitLines uses Font.split (no indent on wrapped
lines, unlike chat's wrapComponents), and TextDisplay keeps the result
until its render state changes; the frame no longer wraps and measures
every display again. A render state is rebuilt only for the ids vanilla
lists (RENDER_STATE_IDS, TEXT_RENDER_STATE_IDS) and starts from the
progress the last frame drew (Display.lastProgress).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:11 +01:00
Ludovic MarieandClaude Opus 5.5 659bfad86c Merge the text-display model fixups (#1718) into the renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:13:00 +01:00
Ludovic MarieandClaude Opus 5.5 76408b734e Text displays: an Align enum, and the text shared rather than copied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:31 +01:00
Ludovic MarieandClaude Opus 5.5 4e3e038c80 Meshing: look up a block's baked models by index, not by model name
Every block the mesher draws asked the model catalog for its models: a
BTreeMap lookup of the state, a Vec of the chosen variants, then for each
variant a clone of its model name and a string-keyed BTreeMap lookup of the
baked model, collected into a second Vec. On generated terrain that was a
fifth of the mesher's time.

The catalog now resolves each variant to its baked model's index once, at
load (CompiledChoices maps its variants), keeps states in a Vec by id, and
the mesher reuses one list of models for every block of a section. The
random draws are the same calls in the same order (CompiledChoices::for_each
is the old collect), so the same models are chosen.

examples/chunk_load_bench.rs generates and lights real terrain with the
vanilla generator and meshes it on one thread, with a checksum of the meshes:
identical meshes, 45.8 -> 35.4 us per section on the Mac mini.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:52 +01:00
Ludovic MarieandClaude Opus 5.5 b0918f3be1 /stopsound: send each targeted server player the stop-sound packet
StopSoundCommand sends ClientboundStopSoundPacket to every target. The
command stopped the server's own sounds instead, so on the server thread
(single player's default) and for LAN guests nothing stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:07:43 +01:00
Ludovic MarieandClaude Opus 5.5 08491bcda3 Java selection: keep Windows JDK homes free of the \\?\ prefix
fs::canonicalize returns verbatim paths on Windows, and the chosen home
goes into JAVA_HOME and the PATH of external preparers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:59:21 +01:00
Ludovic MarieandClaude Opus 5.5 b165506d6e Recipe book: removeRecipes returns the display ids it removed, as vanilla
The /recipe take path no longer computes them a second time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:49:46 +01:00
Ludovic MarieandClaude Opus 5.5 09e95ae5a1 Merge main: keep both appended lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:59 +01:00
Ludovic MarieandClaude Opus 5.5 5a0e650e63 Offhand use: one in-hand check for held and consumed items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:40 +01:00
Ludovic MarieandClaude Opus 5.5 3b586ff903 Server players: one helper finishes a landing's block updates, turtle eggs too
The server's player tick dropped FallEvent::Destroy, so an egg a guest
broke by landing left its neighbours unupdated. Both paths now share the
same helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 6acf7fb6ab server_player_targets harness: end each step by the server's clock
Each step waited a fixed 2.5 s. When the machine is loaded the vanilla server stalls for longer than that (CI on
#1571 and #1585: after Bob's trip to the Nether, vanilla's `tp Bob 4.5 -60 0.5` from the console ran a step late),
so its packets were filed under the next step and the transcripts differed although both servers sent the same.

A step now ends once the server has done its work:
- the console's own `time query gametime` comes back, so the step's console commands have run (the console runs
  its queue in order);
- LocatorBarClient's new `sync NAME` runs `time query gametime` as that player and waits for the reply (not
  recorded), so that player's commands have run and every packet sent before the reply is recorded;
- the server's game time has advanced STEP_TICKS (40) more ticks, then each player sends its position reminder
  and syncs once more.

game_time() moves out of settle() in test_dedicated_server_parity.py so both use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:46:22 +01:00
Ludovic MarieandClaude Opus 5.5 afb3ae79a2 command_exec harness: start the script without the random sequences of mobs that died before it
/random reset * in the ticking group counted 12 sequences in vanilla against Horizon's 11 on one CI run (#1585).
Locally 1 oracle run in 24 did the same, and its extra sequence was minecraft:entities/bat. Before the setup's
gamerules (spawn_mobs and mob_drops off), the oracle ticks until the entities around the player have loaded, a
number of ticks that depends on the machine; mobs spawn meanwhile, and a bat that died then rolled its loot table.
Once mob_drops is off no mob death rolls one (LivingEntity.shouldDropLoot), so the setup now ends with an
unrecorded /random reset *: every run starts the script with no sequence, and the later count holds only what the
corpus itself rolled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:44:02 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 2c80009c70 Scope pre-push history checks to pushed refs
Read Git's pre-push ref updates and inspect the full reachable history of each non-deleted local object ID. Ignore unrelated refs while rejecting private artifacts in deleted files, merge-only paths, and stored history masked by local replacements.

Add regression coverage for branches, tags, raw revisions, multiple updates, deletion-only pushes, and artifact detection.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 04:15:14 +00:00
Ludovic MarieandClaude Opus 5.5 41f8cfa3bc YUNG's API jigsaw draws: yung_jigsaw structures and yung_single_element read as written
Clean-room, from spec-yungsapi.md §2.1 and §3.1 (no mod source read).
A `yungsapi:yung_jigsaw` structure lays out with the jigsaw assembler and
YUNG's draws: the start pool's file entries shuffled, then the rotation,
then a weighted pick in the shuffled order; each jigsaw's target pool is
drawn by weight from its shuffled entries without replacement, an element
whose `name` already has `max_count` pieces skipped at no other draw.
`yungsapi:yung_single_element` places what a single element places; its
pool entry carries the name and limit.

tools/test_structure_layout_parity.py --profile betterdungeons reads the
mod's own data from its jar into an overlay of the 26.3 resources and
compares the mod world's pieces: 82 of 84 Better Dungeons starts match
piece for piece (60 small, 11 skeleton, 11 zombie dungeons, up to 790
pieces); the other 2 sit in 26.3's sulfur_caves, which 1.21.1 lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:08:43 +01:00
rdbtCVSandOpenCode gpt-6.1-sol 5d19fcaa06 Dismount players before command teleports
Reuse stop_riding before applying a player teleport destination so the riding tick cannot return the player to the vehicle. Add vanilla command parity and both-mode LAN regressions for mounted teleports.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 03:38:17 +00:00
rdbtCVSandOpenCode 489b5a4ffa Trading: keep Hero discounts for server players
Preserve the Hero effect captured by the guest's merchant session and cover discounted prices and payments for both single-player modes.

Fixes #1365

Co-Authored-By: OpenCode (gpt-6.1-sol) <[email protected]>
2026-10-25 03:36:35 +00:00
rdbtCVSandOpenCode gpt-6.1-sol 342cb6c500 Fix Java compiler/runtime selection during preparation
Pair PATH tools from a compatible JDK and always forward its home to external preparers so Java 25 exporters run with a compatible runtime.

Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 03:32:23 +00:00
rdbtCVSandOpenCode eed98be9cf Merge main and retain both LAN regression checks
Combine the shulker bullet check with main's new falling-entity coverage.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:31:45 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 859236bd9d Fix recipe removal notifications for server players
Notify connected players of the display IDs forgotten by /recipe take.
Discard queued additions for recipes the player no longer knows.
Add LAN regression coverage for both single-player paths.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:25:56 +00:00
RVZO6andCodex 07519df062 Handle server stop-sound packets through the existing sound engine
Decode source/event selection flags, including 26.3 UI source 10, and route them to Audio.stop_sounds. Compare all 36 native Java codec selections and round-trip their bytes; reject invalid sources and truncated packets.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 23:23:59 -04:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol b1eae8a5b8 Tests: keep spectator regression separate from new LAN checks
Move the spectator-travel check before the existing villagers check so it merges cleanly with the newly added falling check on main.

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 03:13:12 +00:00
rdbtCVSandOpenCode cc65eeec82 Make shulker bullets visible on server clients
Assign mob-fired projectiles IDs when they are added and recognize shulker bullet spawns on remote clients. Extend the live LAN checks for both hosting modes.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-25 03:11:09 +00:00
RVZO6andCodex 792ac58f46 Render server text displays with billboard transforms and styled glyphs
Apply metadata, lifetime and position/transform interpolation; render wrapped/aligned text, background, opacity, shadows, brightness and see-through flags. Upload dynamic font glyphs and expose actual emitted vertices for end-to-end checks. Verify 108 matrices with the native client renderer and exercise a real Java-server/Rust-client fixture.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 23:10:47 -04:00
rdbtCVSandOpenCode GPT-6.1-sol 578f077347 Merge main and retain both LAN regression checks
Resolve the concurrent farmland and falling check additions in tools/lan_e2e.py.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 03:06:23 +00:00
rdbtCVSandOpenCode b6ec56ac9b Merge origin/main into double-chest click fix
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 03:00:14 +00:00
rdbtCVSandOpenCode gpt-6.1-sol e334dd3d55 Fix offhand eating and drinking cancellation
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 02:59:33 +00:00
Ludovic MarieandClaude Opus 5.5 8948bf90c4 Structure layout harness: Horizon's starts piece by piece against the structure oracle
The clean-room structure oracle saved, for 31 starts of vanilla structures
(villages, outposts, trial chambers, shipwrecks, ruined portals, desert
pyramids over seeds 1234, 2 and 42), the no-mod world's pieces. A new
structure_pieces probe lists the starts a chunk owns, piece by piece, and
tools/test_structure_layout_parity.py compares them with those saves.

22 of the 31 match piece for piece (every village and outpost that 26.3
still places there, and the horizontal boxes of the code-built pieces).
The other 9 differ between 1.21.1 and 26.3 themselves: 26.3 vanilla's own
pieces there (VanillaChunkGenerationOracle scan) equal Horizon's for all 18
starts that differ from 1.21.1. No vanilla jigsaw fix is needed.

Templates now keep the id they were loaded under, so a piece names its
element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:57:01 +01:00
rdbtCVSandOpenCode GPT-6.1-sol 753a99b933 Fix crop updates after server-side farmland trampling
Forward landing block-write events to notify_written so unsupported crops break, drop their items, and synchronize to clients. Add LAN regression coverage for the host and guest in both hosting paths.

Co-Authored-By: OpenCode GPT-6.1-sol <[email protected]>
2026-10-25 02:55:30 +00:00
RVZO6andCodex dc1e16f472 Model every text-display metadata field against the Java wire codec
Add a native Java oracle for the actual Display/TextDisplay accessors and encoded metadata, with 513 combinations of flags, opacity, billboard mode and transforms. Preserve styled text and partial updates.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-24 22:52:51 -04:00
rdbtCVSandOpenCode c36e81916b Fix double-chest inventory and hotbar clicks
Co-Authored-By: OpenCode (GPT-6.1-Sol) <[email protected]>
2026-10-25 02:24:16 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 6e86e07781 Multiplayer: keep spectator collision state after dimension travel
Restore the current game mode's spectator flag when remote_teleport creates a replacement player body. Add an end-to-end wall traversal regression for Nether travel and return, covering the integrated-server host, LAN guest and in-frame host.

Fixes #1299

Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-25 02:22:41 +00:00
rdbtCVSandOpenCode gpt-6.1-sol e820e9e1af Website: use one navigation bar across pages
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 01:40:39 +00:00
Alexander Krylov 961af30997 Clippy: clear the two warnings that the whole tree fails on
The update tests' scratch helper only feeds unix-gated tests, and
receive_decorated takes the box the queue already put the protos in.
2026-10-25 04:34:05 +03:00
Alexander Krylov c10261f008 Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
predicate_property was the same fifteen lines in block_feature and
state_provider; it is worldgen::bound_property now. The mojang link helpers are
unix-only in use but worth testing everywhere, so they are allowed to look dead
elsewhere, which clears the launcher's -D warnings red on Windows. The bots
harness recovers from a poisoned lock instead of panicking.
2026-10-25 03:58:02 +03:00
Alexander Krylov a96dccf691 Save: the write paths report a failure instead of dropping it
The moderation lists on a dedicated server, command storage, boss bars, the
world clocks, the difficulty and the emergency save before a crash all used
let _ = and lost the error. write_dat also swallowed the rename that moves the
current file aside, which made the following rename fail on the existing path
with nothing reporting it.
2026-10-25 03:57:31 +03:00
Alexander Krylov 6d1f6965d2 Packs: an untouched vanilla directory is mirrored on Windows too (#1688)
link cannot symlink a directory on Windows, so mirror has to recurse into one
itself, but it only did so when a pack had filters. An untouched directory fell
through to the link branch and vanished from the overlay, which took
textures/font and the panorama with it and made a pack that overrides a font
crash the client before its window.
2026-10-25 03:57:00 +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
some-du6eandClaude Opus 5.5 f66c257ae5 Advancement chat hover: normalise the title with canonical_component
scoreboard::canonical_component already re-encodes a component as vanilla
does, list form included, so decorate_name uses it instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:46:24 -06:00
some-du6eandClaude Opus 5.5 9f1a880951 Advancement chat hover: a title in list form keeps its first element's style
A component list is its first element with the rest as siblings, so
decorateName merges the frame colour under that element's style. The
parsed list moved a styled first element down a level, which gave the
description the frame colour instead of the title's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:43:58 -06:00
some-du6eandClaude Opus 5.5 7a7bfdde79 Advancement chat hover: the description takes the title's own style
Advancement.decorateName merges the frame colour under the title's own
style and appends the description after its siblings. Built from the
parsed Text, a styled root with siblings had its style moved down a level,
so the description showed in the frame colour instead of the title's
(a gold, bold title gave a green description). The hover is now built on
the title's JSON, and the local spans are decoded from that JSON, so an
in-frame player sees the description coloured as a client does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:39:57 -06:00
some-du6eandClaude Opus 5.5 96b6ed4916 Test that a connection reaches IPv4 first and report the first address's error
A loopback test listens on both [::1] and 127.0.0.1 and checks the
connection lands on IPv4 although IPv6 is listed first; it fails without
the sort. Another checks the fallback to IPv6 when IPv4 refuses. When every
address fails, the error is now the first address's (the one vanilla tries)
rather than an IPv6 fallback's, which is often only "no route to host".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:33:22 -06:00
rdbtCVSandopencode 6240124557 Slow Falling clears the fall distance so landing does not hurt
The effect capped downward gravity but left the fall distance to accumulate, so the landing tick still dealt fall damage. Clear it at the start of check_fall_damage, where both local travel and the server thread's replay of a guest's move pass.

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:21:59 +01:00
Ludovic MarieandClaude Opus 5.5 e55b5b7c00 Boss events: custom bars first, as a joining player gets them
CustomBossEvents.onPlayerConnect adds a joining player to its bars before any
entity, dragon fight or raid tells it about theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:21:03 +02:00
rdbtCVSandopencode b0a5334cb5 Creative inventory: highlight hovered hotbar slots
The item-tab hotbar loop only set the tooltip, so the nine slots never
showed the white hover fill the 5x9 grid uses. Apply the same fill,
including over empty slots, before picking the tooltip item.

Fixes #1341

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:17:28 +01:00
Alexander Krylov a93c1507f9 Launcher: a version removal retries a locked file and leaves the UI thread
remove_dir_all stops at the first file another process still holds and leaves
the rest deleted, so a removal that worked reported os error 5. Retry five times
250ms apart and treat a missing folder as done. The window froze because the
deletion ran from the update loop; it now runs on a thread and reports back
with Msg::Removed, as install already did.
2026-10-25 03:16:56 +03:00
Ludovic MarieandClaude Opus 5.5 a26297058a Trims: a Horizon host sends them too
The host wrote no trim component, so single player on its server thread,
LAN guests and dedicated-server players saw armour without its trim.
network_stack now takes the connection's synced-registry lookup for any
registry and writes Component::Trim when both names are in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:15:25 +02:00
Ludovic Marie 149864c3b4 Merge remote-tracking branch 'origin/main' into perf/face-cull-cr1
# Conflicts:
#	crates/horizon-client/src/mesher/leaf_culling_tests.rs
2026-10-25 02:15:14 +02:00
Ludovic MarieandClaude Opus 5.5 42afd721e7 Server packs: the download toast shows over the connection screen
A server sends its packs in the configuration phase, while the joining
screen covers the frame; vanilla's ToastManager draws over every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:04:39 +02:00
Simon Dräger 4d3f3b6391 Load fireballs from NBT as live projectiles 2026-10-24 17:02:57 -07:00
Ludovic MarieandClaude Opus 5.5 75c009e3fc Boss events: one path from the host's diff to every overlay
The host diffed each viewer's boss events into a Change enum, then turned the
changes into packets for guests and, through a second match, into setter calls
on the window's own overlay; the client turned packets into the same setters a
third way. The diff now yields ClientboundBossEventPacket values, guests get
them as packets and the local overlay applies them with BossOverlay::apply,
the same function a server's packets reach. Gone: Change, Snap, Sent,
apply_local, boss_packet and the overlay's nine-argument add and setters.
UUIDUtil.uuidFromIntArray joins uuid_ints in lan::playerdata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:53:03 +02:00
Alexander Krylov e30166f23b Options: Horizon's own captions read a bundled language namespace (#1672)
The settings screen's own options (controller, vibration, dead zones and the
rest) are not vanilla options and have no vanilla keys, so their captions
were English literals. They now read horizon.options.* from assets/horizon/lang,
which the client bundles as one more language source placed first, so a pack
or the prepared root can still override it. English and Russian ship here.
2026-10-25 02:51:11 +03:00
socksproxandDevin ba09b086a6 Multiplayer: drop the player name from the join loading line
The pre-world overlay said "Joined as {name}; loading terrain..." after
Event::Joined. Vanilla's LevelLoadingScreen only ever draws the
multiplayer.downloadingTerrain text there — use that translation, no
name.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 01:49:31 +02:00
Ludovic MarieandClaude Opus 5.5 f07d66c7cf Trims: their own arm in Component::read
The trim decode sat inside the read-past fallback and matched the kind
there; it now has a TRIM arm, and the fallback only reads past. Long
comment lines wrapped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:44:21 +02:00
Ludovic MarieandClaude Opus 5.5 6d735c26d3 Server packs: the download toast follows DownloadedPackSource's notifier
What the toast showed differed from vanilla 26.3 in several ways:
- the title and the failure line had no arguments: download.pack.title takes
  the request number and the batch size, download.pack.failed the failures
  and the batch size;
- sizes read "1.3 KiB" style; com.mojang.realmsclient.Unit.humanReadable gives
  "1.3 KB" ("512 B" below 1 KiB, half-up rounding), checked against the jar;
- each update didn't restart SystemToast's 5 s display time, so a download
  longer than 5 s lost its toast, and the Notification Time option was ignored;
- a finished batch removed the toast at once instead of forceHide's slide out;
- the failure title sat at y 7 in white; SystemToast draws titles yellow, and a
  title without a message at y 12;
- a second batch replaced the first one's channel and lost its events.

The download thread now reports HttpUtil's listener calls (requestStart,
downloadStart with the Content-Length, downloadedBytes, requestFinished), and
each batch gets a PackNotifier (createDownloadNotifier) with its own toast. The
toast is a shared toasts::SystemToast; the system background moved to
toasts.rs with a y, so the pack toasts stack under an advancement toast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:40:53 +02:00
Ludovic MarieandClaude Opus 5.5 3dc14b6eda Advancement chat names: the description goes after the title's siblings
Advancement.decorateName appends a new line and the description to the title's copy;
adv_name replaced the title's extra list, so a title built from siblings (a
plugin's {"text":"","extra":[...]}) reached clients as just the description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:29:12 +02:00
Alexander Krylov f500be1b5e Title: the main menu labels follow the game language (#1672)
The screen built them from literals, so the buttons stayed English in every
language while the world select screen next to them was translated. Each has
a vanilla key the prepared resources already carry; Friends is Horizon's own
and reads horizon.menu.friends with the English text as fallback.
2026-10-25 02:28:27 +03:00
Ludovic MarieandClaude Opus 5.5 3e3ab7a413 Merge main into fix/ipv4-first: IPv4 first for SRV targets too
Main's SRV resolver (#1578) added Connection::connect_server; both connect paths now
go through connect_addresses, which puts IPv4 first, as ServerAddressResolver.SYSTEM's
InetAddress.getByName does for the original host and the SRV target alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:24:52 +02:00
Ludovic MarieandClaude Opus 5.5 fab71df185 Mesher: sort solid quads in the meshing workers, not in Mesher::mesh
Mesher::mesh keeps model order, which the leaf-culling test pairs with
the baked faces; the section and tile workers sort what they upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:21:23 +02:00
Ludovic MarieandClaude Opus 5.5 8698b4ad0c Shader packs, clean room 13: loading the terrain program
load_post takes the game's terrain vertex inputs and, given them, loads
the first program of gbuffers_terrain's fallback chain that translates
(gbuffers_terrain, textured_lit, textured, basic). Program loading is
shared by the post-processing and gbuffers programs.

The translator no longer rewrites a variable, parameter or member that
has the name of a sampler (`float specular` beside `uniform sampler2D
specular`), which kept Complementary's gbuffers programs from compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:20:16 +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
Ludovic MarieandClaude Opus 5.5 f31c18aa6c Shader packs, clean room 12: the block atlas packs sample
Packs sample blocks from one 2D atlas; Horizon keeps them in sprite
arrays. While a pack is loaded, every layer and its mip levels are
copied into a grid, and the animator reports the layers it rewrote so
only those are copied again. The sprite arrays gain COPY_SRC.

The gbuffers pass binds it in the next slice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:25 +02:00
Ludovic MarieandClaude Opus 5.5 a2a91f98f9 Shader packs, clean room 11: translating gbuffers programs
translate_gbuffers places a vertex-input glue the game writes (it knows
its own vertex formats) in front of a pack's gbuffers program, sets the
compatibility matrices from gbufferModelView/gbufferProjection and the
lightmap texture matrix, and gives declared attributes the game's values
(zero when it has none).

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:15 +02:00
rdbtCVSandOpenAI Codex GPT-6.1 c41a053c15 refactor: separate client world entry from authoritative loading
Give integrated and dedicated startup a synchronous loading entry point without client deferral or integrated-server selection. Keep client orchestration and background loading unchanged.

Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-24 23:53:29 +01:00
rdbtCVSandOpenAI Codex 0d1736da8f Make startup and world-loading execution roles explicit
Separate client, integrated-server and dedicated-server roles from connection state. Use roles for startup, presentation work and server dimension loading while preserving in-frame client simulation.

Co-Authored-By: OpenAI Codex (GPT-6.1) <[email protected]>
2026-10-24 23:53:00 +01:00
Alexander KrylovandHermes Agent 977ef7181f Data: /data get entity writes a mob's live riders
A mounted mob answered "Found no elements matching Passengers": the command
built the entity with the flat serializer, so `Passengers` was never in the
tag, even though the game had the relationship and saving a world kept it.

Persistence already writes the tree (`Game::mob_tree_nbt`, which also carries
the leash), and vanilla's command path uses the same one, so the command reads
it now.

Fixes #1519.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 01:51:47 +03:00
Ludovic MarieandClaude Opus 5.5 466b1de4c4 Shader packs: fewer passes and no needless loads in post-processing
- A buffer is cleared only when a program reads it before one writes all
  of it; the clears left ride in the blit pass as attachments the blit
  shader leaves alone, instead of passes of their own.
- The blit writes depthtex0 only when some program reads it.
- Attachments a pass overwrites whole (the blit's colortex0 and depth,
  every composite output, final's window) start cleared rather than
  loaded, which spares a tiled GPU reading them back first.
- The blit's bindings are kept until the world target changes size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:46:07 +02:00
socksproxandDevin 282ecf16a9 Tests: fix boss-event verify failures
boss_event_round_trips missed its #[test] attribute, failing clippy's
dead-code lint; unknown_packets_keep_their_payload decoded boss_event,
which this PR gives a real decoder, so the garbage payload no longer
comes back Unknown — it uses player_look_at, which still has none.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:45:16 +02:00
Ludovic MarieandClaude Opus 5.5 1776d7ca5c Shader packs, clean room 10: post-processing over Horizon's world
The first slice that draws: with HORIZON_SHADER_PACK=<name>, the pack in
shaderpacks/<name> (folder or zip) runs its prepare, deferred, composite
and final programs over the world, under the hand and the HUD. Gbuffers
programs come later, so the composites read Horizon's own picture.

- The world renders into its own target (as with a resolution scale); its
  colour becomes colortex0 and its reverse-Z depth becomes depthtex0 in
  GL's window depth (1 - d for the same planes), rows flipped to GL's
  order. depthtex1 and depthtex2 read the same depth for now.
- colortex buffers in the formats the pack asked for (the nearest one
  wgpu renders to), created only when a program uses them, each with two
  textures: programs read main and write alt, the buffers a program wrote
  flip after it, flip.* keys and deferred_pre/composite_pre apply, and
  the mapping carries over to the next frame. Clears at the start of the
  frame with the pack's colours or the defaults.
- noisetex (256x256 white noise, repeat), a lit stand-in behind shadow
  samplers until shadow maps exist, a 1x1 stand-in for other names; the
  frame uniforms of the previous commit and the translator's own block.
- final draws onto the window; without it colortex0 is shown.
- Pipelines are built through validation scopes: a program wgpu refuses
  is reported with the full error and skipped. The dimension's folder is
  reloaded when the player changes dimension.

Nothing changes without the environment variable: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:46:24 +02:00
Ludovic MarieandClaude Opus 5.5 7b56b59e7c Mod registry: the submission form's service (crates/horizon-mod-registry)
Its own workspace, as the telemetry server's, for its own Railway service.
GitHub sign-in with no scope (the user's token reads the login once and is
dropped), signed-cookie sessions, an origin check and a CSRF token, six
submissions per account and sixty in all per hour. A submission runs the
file's own checks (horizon_mods::index::inspect, the signature, the entry's
fields through registry::project), uploads it to the registry's `staging`
release as <sha256>.<extension>, writes mods/<id>.toml on a new branch with
the submitter as co-author, and opens the pull request. Only an entry's
owners publish updates; a key change is a pull request by hand. Shader packs
are refused until their format exists.

Tested end to end against a mock GitHub: sign-in, the signed sample mod,
what lands on GitHub, and what is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:43:20 +02:00
Ludovic MarieandClaude Opus 5.5 7d76923a04 Mod registry tools: the registry's entries, build and review
- `horizon_mods::index::registry`: the registry repository's
  `mods/<id>.toml` and `revoked.toml`, `build` (every entry and file
  checked, every problem listed, the index written) and `review` (a
  published file never changes or disappears, a kind never changes;
  non-owner, owner and key changes pointed out).
- `cargo horizon-mod registry build|review|check|list` for the registry's CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:57 +02:00
Ludovic MarieandClaude Opus 5.5 59281c5c73 Mod registry: cargo horizon-mod check, each kind's own checks
- `horizon_mods::index::inspect`: each kind's own check (a mod's manifest
  and content, a modpack's manifest and overrides, a resource pack's
  pack.mcmeta), returning what the file says; `check_file` now compares
  those facts with the index. Shader packs and unknown kinds are refused,
  and `index::open` says which kinds the registry takes: shader
  submissions stay closed until Horizon's own format exists.
- `cargo horizon-mod check <file>`: those checks plus a trial run of a mod
  in the sandbox (ticks, frames, a chat line, each command), text or JSON.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 303d497a67 Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
The values a pack's post-processing programs read once per frame, in
GL's conventions: camera position and the previous one, eye altitude,
up/sun/moon/shadow-light positions in view space (the sky's own frame for
the sun and moon), sun and shadow angles, world time, day and moon phase,
frame counter, frame time and run time, view size and aspect, near and
far, rain, smoothed wetness, thunder, fog colour, start and end, sky
colour, eye in fluid, blindness, night vision, darkness, brightness, GUI
hidden, eye brightness and its smoothed value, and the model-view and
projection matrices with their inverses and last frame's. The projection
is GL's perspective with the world's near and far planes times vanilla's
view bobbing. Each value is written as the type the pack declared.

The game passes what only it knows (day time, weather levels, the fluid
at the camera, light at the camera, blindness, F1, brightness) in the
renderer's Frame when HORIZON_SHADER_PACK is set.

Fix (slice 4): programs that draw to buffers read gl_FragCoord through a
variable only final set; every target sets it now, with a test. Found by
the first frame drawn with a pack.

Nothing reads the uniforms yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:26:42 +02:00
Ludovic MarieandClaude Opus 5.5 2716ad41be Renderer: Metal's flat draws and occlusion culling switch on by sections in view
Both switched on past 4,096 section draws, measured when a section had
2.2 to 2.5 draws. A section's draw count now depends on the sides it shows
the eye, so the bars count sections in view: 1,600, where 4,096 draws fell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:16:05 +02:00
some-du6eandClaude Opus 5.5 be34f4cd5f Connect to a server's IPv4 address first, as vanilla does
Vanilla resolves a server with InetAddress.getByName, and Java's default
(java.net.preferIPv6Addresses=false) puts IPv4 addresses first. Horizon
tried addresses in the system resolver's order, which on macOS puts IPv6
first: a server whose IPv6 address resets the connection showed
"Connection reset by peer (os error 54)" where vanilla joined.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:04:45 -06:00
Ludovic MarieandClaude Opus 5.5 dbdc0389fe Launcher: Horizon modpacks from the registry, each its own profile
A registry modpack (.hpack) now shows under Modpacks with the Horizon
source, with its mods and what each may do, and installs into
horizon-packs/<id>/: the checked pack, pack.json and home/, where each of
its mods is downloaded and checked like any registry mod (pinned to the
pack's version and SHA-256) and turned on or off as the pack says. Its
overrides are copied on the first install only. A pack whose mod was taken
down installs nothing and leaves nothing behind. The Modpacks tab lists
installed registry packs with Play and Remove (confirmed); they play through
modpacks::apply_profile with their own HORIZON_HOME, taken-down mods turned
off first. CLI: `mods install --source horizon <pack>`, and `modpack
list|play|remove` see registry packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 f602ad6fe3 Launcher Mods page: Horizon's registry beside Modrinth
The source switch now picks Modrinth or Horizon (CurseForge stays coming
soon). On Horizon each result says whether its files are signed and what
the mod may do, in the game's words, before Install; no Java check, no
download count, no Modrinth filters. Installed Horizon mods get an
on/off switch (horizon-mods.txt); one the registry took down is turned
off when the page reads the index and can't be turned back on.
Registry icons load from its GitHub Pages. HORIZON_LAUNCHER_SOURCE=horizon
opens the page on it, for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 b49fd602df Launcher: install from Horizon's registry, enable and disable, takedowns
Mods install into horizon-mods/<id>.hmod (turned on) and resource packs into
resourcepacks/, each downloaded to mod-browser/downloads/ and checked by
horizon_mods::index::check_file (size, SHA-256, signature, manifest) before it
is moved into place. Files the index takes down are turned off when the index
is read or the game starts, and can't be installed or turned back on. CLI (and
so MCP): --source horizon, mods enable|disable, and search shows whether a
project is signed and what a mod may do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 7e513d2d5c Launcher: Horizon's registry as a mod browser source
A browser source for Horizon's own registry (docs/api/mod-index.md): it reads index.json
(HORIZON_MOD_INDEX for a test registry, cached in the game folder for when the registry can't
be reached), searches it by kind and words, lists versions with the SHA-256 the index gives,
and blocks the versions it says were taken down. Downloads accept the registry's release host
and check SHA-256; file:// files only from a local index.

Installing, the takedown check before play and the CLI come next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
some-du6eandClaude Opus 5.5 4ad05aa94a Show armour trims on items from a server
Decode the trim component as its material and pattern registry ids when both
are references (a direct holder stays read past) and name them through the
server's synced trim_material and trim_pattern registries, as enchantments
are, so worn and held trimmed armour draws its trim in multiplayer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:29 -06:00
some-du6eandClaude Opus 5.5 8c611e677b Advancement titles and descriptions show a whole text component
A server's custom advancements often name themselves with an empty text and
the words in its siblings ({"text":"","extra":[...]}); only the top-level
text or translate key was read, so their tooltips were blank. Read them as
the component they are (ComponentSerialization), with siblings, styles,
translation arguments and fallbacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:44:28 -06:00
Ludovic MarieandClaude Opus 5.5 3f1a7b28af Placement prediction: one helper for predicted blocks, the sound where the block went
- predict_use and predict_place set their blocks through one
  predict_blocks (retainKnownServerState), instead of two copies of the
  loop.
- remote_can_place returns where BlockItem.place put the block, and the
  placer hears the place sound there: before, set_block only played it at
  the clicked face's neighbour, so a block that replaced grass or snow
  where the player clicked made no sound for its placer now that the
  server leaves the placer out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:39:13 +02:00
Ludovic MarieandClaude Opus 5.5 6b2ec5f830 Shader packs, clean room 8: a dimension's post-processing programs
What the renderer needs to run a pack's post-processing, still without
wgpu: the programs of one dimension (prepare, deferred, composite and
final with their numbered variants, in the order they run), each
preprocessed and translated, with the buffers it writes and the flips
that follow it, and the buffer settings its stage files carry.

- A dimension's folder (world0, world-1, world1) replaces the base folder
  when it exists. program.<name>.enabled expressions over switch options,
  flip.<program>.<buffer> keys and the virtual deferred_pre and
  composite_pre. Programs with a geometry stage, or that fail to
  preprocess or translate, are reported and skipped.
- tests/packs/post-test: Horizon's own small test pack (tint, depth in
  colortex2, depth bands on half the screen, vignette).

Black box over the public test packs: every dimension of every pack
loads its post-processing programs (2 to 9) with no note.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Ludovic MarieandClaude Opus 5.5 d064dba945 Shader packs, clean room 7: buffer settings and output lists from stage files
The configuration packs write into their stage files, read from live code
only (the preprocessor has dropped false branches, so an option's choice
is the one in effect):

- the buffers a fragment shader writes: the last live DRAWBUFFERS or
  RENDERTARGETS comment, at most 8, buffers 0-7 without one;
- per colortex buffer (names and aliases): <buffer>Format in block
  comments, with every format name of the spec in any case, as channels,
  bits and kind; <buffer>Clear, ClearColor and MipmapEnabled.
  A later file changing a setting, an unknown format and a value of the
  wrong type are reported with their file and line. Buffer 1 becomes
  RGBA32F when a program declares gdepth and the pack set no format.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Simon Dräger 72f88befb8 Allow spaces in horizon-ctl screenshot paths 2026-10-24 13:21:23 -07:00
Simon Dräger 891e1c1283 Run client mod hooks on the server thread 2026-10-24 13:21:23 -07:00
Simon Dräger d4b2e24535 Add mod command capability and Predator Missile example 2026-10-24 13:21:23 -07:00
Simon Dräger d83b3e18bc Keep mob pitch on /tp 2026-10-24 13:21:23 -07:00
Ludovic MarieandClaude Opus 5.5 fb4897ec09 Website: /mods/submit, the registry's submission form
Sign in with GitHub through the registry's service (no scope), then send
the file and its page; the service checks the file, stages it and opens the
pull request, and the page links to it or says what to fix. A file named
<id>-<version>.<ext> fills in its kind, id and version. Shader packs are
listed and disabled until Horizon's own format exists. Caddy sends
/api/mods/* to REGISTRY_UPSTREAM (unset: the form says it isn't open), and
"Submit yours" now leads here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:10:05 +02:00
socksproxandDevin a5f5d8df34 Server packs: the download progress toast
#1570 landed the client pack manager without vanilla's
PackDownloadNotificationComponent: the SystemToast that follows a
download with download.pack.progress.percent or .bytes and stays as
download.pack.failed when a batch fails. The download queue reports
requestStart/downloadedBytes/downloadFinished on a channel (vanilla's
DownloadProgressListener), the game ticks them into a toast it slides
in for the vanilla 5 seconds with the toast in/out sounds.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:49:01 +02:00
levzzzandClaude Opus 5.5 90b5e162ae Play a placed block's sound once for its placer
BlockItem.place plays it with playSound(player, ...): the server sends it to
the others, and the placer's client plays it as its block appears. The
server sent it to the placer too, so each block sounded twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:38:39 +05:00
Ludovic MarieandClaude Opus 5.5 b61b25f80f Shader packs, clean room 6: composite-style programs to naga
A program's preprocessed GLSL, any version from 110 to 460, compatibility
or core, rewritten into the Vulkan-flavoured GLSL 450 that naga's frontend
reads, then parsed and validated into naga modules with the program's
interface: its uniform block (offsets from naga's layout), its samplers
and their bindings, its output locations. The rewrite works on
preprocessing tokens and keeps the pack's lines, so every error points at
the pack's own file and line through the preprocessor's line map.

- Loose uniforms go into one block shared by both stages; bool is stored
  as int. Samplers are split into texture and sampler objects, combined
  again where used, and passed as two arguments to the pack's functions.
- The compatibility built-ins become declarations of ours (prefix hz_):
  gl_Vertex, gl_MultiTexCoordN, the matrices and ftransform(), gl_FragData,
  gl_FragColor, gl_TexCoord, gl_FrontColor, gl_Fog (from vanilla's fog),
  texture2D and the other old texture functions, shadow2D; varyings get
  locations by name across the stages; words that became keywords after
  GLSL 1.x are renamed in old code.
- naga gaps covered: modf, conditional initializers of globals (run by our
  main), and unsized arrays sized from their constructor.
- The pack's main is called by one of ours that draws the quad from (0,0)
  to (1,1) and adapts GL's conventions: clip y negated for every target but
  the window, so memory rows are GL's rows and gl_FragCoord needs no change;
  clip z from GL's [-w, w] to [0, w], so stored depth is GL's window depth.
  In final, gl_FragCoord.y is counted from the bottom of the window.

Black box over the public test packs: all 105 composite-style programs
(composite, deferred, final, prepare, begin, shadowcomp of the base and
dimension folders) translate and validate.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:35:37 +02:00
socksproxandDevin d742d725f4 Client: the boss-overlay — BossEvent bars, wither darkening and world fog
The clientbound boss_event packet (add/update/remove/style/progress), a
BossOverlay that renders each bar, darkens the world under a wither, and
suppresses the create-the-world fog — vanilla's BossHealthOverlay and
GameRenderer behaviour on a real server.

Co-Authored-By: Devin <[email protected]>
2026-10-24 21:29:08 +02:00
Ludovic MarieandClaude Opus 5.5 787c3a2f19 Website: /mods, Horizon's registry of mods, modpacks and resource packs
The list (search, a filter per kind) and a page per project: what a mod
may do in the game's own words, Signed or Unsigned, how to install it
from the launcher, the CLI or by hand, its file, SHA-256 and versions,
and links to its source, its registry entry and the report form. Baked
from the published index at build time (HORIZON_MOD_INDEX for a test
index) and refreshed by the page; Caddy serves /mods/<id> from its baked
page, else the list page, which shows a project published since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:20:34 +02:00
Ludovic MarieandClaude Opus 5.5 7c1dd49810 Renderer: leave out solid quads that face away from the eye
Meshing sorts each section's and distant tile's solid quads into seven
buckets: one per axis direction (all four corners in one axis plane, both
triangles wound the same way) and one for everything else, and keeps each
axis bucket's extreme plane. A quad facing +X at x = p is a back face from
an eye at x <= p, which the solid pipeline culls anyway, so the frame draws
only the buckets that can face the eye, merged into runs of adjacent
buckets: one to three indirect draws per section on the existing page-run,
occlusion-culling and flat paths. The eye comes from the frame's
view-projection, so view bobbing is exact. The picture does not change;
`horizon-ctl section-sides on|off` switches it for A/B checks.

Written from scratch from the general back-face technique; no other
renderer's code or Horizon's removed code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:13:00 +02:00
Ludovic MarieandClaude Opus 5.5 f239955997 Shader packs, clean room 5: the GLSL preprocessor and #include
A stage file goes through comment removal, #include, conditionals and
macro expansion, and every output line remembers the pack file and line it
came from, so later errors point at the pack's own code.

- #include "x" from the including file's folder, "/x" from the shaders
  folder, expanded in live code only (an include inside a false #if is not
  read), ten levels at most, paths that leave the pack refused; a file is
  read once per run however often it is included.
- #define, #undef, line continuations, __LINE__ and __VERSION__; #version
  and #extension kept apart for the translation's own header; the block
  comments of live code kept for the directives packs write in them
  (DRAWBUFFERS, buffer formats); #error and unknown directives diagnosed.

Black box over the public test packs: every stage file of the base and
dimension folders (126 to 219 per pack) preprocesses with no diagnostic.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:29 +02:00
Ludovic MarieandClaude Opus 5.5 4c65ed2f5c Shader packs, clean room 4: function-like macros and the standard macros
The macro engine the GLSL preprocessor needs, shared with the .properties
reader: function-like macros take their arguments (expanded first, raw
beside ##, which pastes) when a ( follows their name; a macro is still not
expanded inside its own expansion. Text is cut into preprocessing tokens,
so numbers such as 1e5 or 0x1F are never mistaken for names.

The #if/#elif/#else/#endif state moves out of the .properties reader so
the preprocessor uses the same code.

Standard macros: MC_GL_VERSION and MC_GLSL_VERSION 460, the 24
MC_RENDER_STAGE_* as ascending integers in draw order, render and shadow
quality 1.0, MC_HAND_DEPTH 0.125, MAX_COLOR_BUFFERS 16.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:07 +02:00
levzzzandClaude Opus 5.5 352645b417 Predict block placement on the client, so fast bridging stops losing clicks
MultiPlayerGameMode.useItemOn places the block on the client's level at
once, kept under the use's sequence until BlockChangedAck. Without it the
next fast click aimed past the block still on its way from the server, the
server refused it, and the arm swung for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:07:31 +05:00
Ludovic MarieandClaude Opus 5.5 397d529db7 Shader packs, clean room 3: .properties files with conditional directives
shaders.properties and the id files (block, item, entity, dimension):
key=value lines in file order with their line numbers, # comments, a value
continued on the next line by a trailing backslash, and the conditional
directives evaluated with the standard and option macros. A # line is a
directive only when its first word is one, so a commented-out key such as
#ifdefault=1 stays a comment. #define and #undef apply to the lines after
them in every one of these files: a public pack defines a macro inside
#ifdef MC_OS_MAC in its shaders.properties and tests it further down. A
later duplicate key replaces the earlier one; broken conditionals, lines
that are not key=value and duplicates are kept as notes for the pack's log.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:33 +02:00
Ludovic MarieandClaude Opus 5.5 eb0a6f74e6 Shader packs, clean room 2: macros and #if conditions
The integer arithmetic of #if/#elif that a pack's .properties files and,
later, its GLSL preprocessor share: defined X and defined(X), decimal,
hexadecimal and octal literals, the C operators with C precedence, macros
expanded to their values (a macro is not expanded inside its own
expansion), and an identifier that is not a macro read as 0. A bad
condition says why. The standard macros that need no renderer:
MC_VERSION, from the pinned version by the documented rule (26.3 is
260300), and the system's MC_OS_*.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:05 +02:00
some-du6eandClaude Opus 5.5 9223b401e7 Decode every item data component a server sends
Horizon decoded 25 of 26.3's 122 data component types. Item data has no
length prefix, so a stack with any other component (an armour trim,
banner patterns, pot sherds, a stew's effects, a plugin's item) made the
whole packet undecodable and it was dropped: on a Paper server the
inventory arrived empty until each slot was clicked, the advancements
screen said "There doesn't seem to be anything here..." and the recipe
book was empty.

Every other component is now read to its end with its vanilla
StreamCodec (item/skip.rs) and kept as its bytes, written back as they
came; components without a network codec go as their NBT. The client
leaves them out of the item's components, so the item keeps its
prototype's values for them.

tools/test_item_wire_parity.py checks it against vanilla: the oracle
encodes 374 stacks with ItemStack.OPTIONAL_STREAM_CODEC (every default
component value of every item, SNBT fixtures for the rest, direct
holders and removals built in Java), covering all 122 component types,
and the Rust probe must consume every byte, find the same component ids
and encode the read-past ones back to the same bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:20:45 -06:00
Ludovic Marie db758a622f Merge remote-tracking branch 'origin/main' into pa1/remove-face-groups
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-24 18:54:45 +02:00
Ludovic MarieandClaude Opus 5.5 7a1638a190 Renderer: remove the section face groups modelled on another renderer
#503 grouped each section's solid quads by the axis side they face and
skipped the groups facing away from the eye. Its code and PR named an
internal class of another project as the model. Horizon doesn't build on
another project's code, so the grouping goes and every section draws its
solid quads in one draw, as vanilla does; the GPU's back-face culling
discards the back faces, so the picture is unchanged.

This costs frame time, measured on the Mac mini in the PR. A clean-room
version can come back later, designed from vanilla and from first
principles only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:24:46 +02:00
417 changed files with 39017 additions and 2333 deletions

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --history
exec python3 tools/check_public_tree.py --pre-push
+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 }}
+34
View File
@@ -0,0 +1,34 @@
name: mod-registry
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
pull_request:
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets --locked -- -D warnings
- run: cargo +stable test --locked
+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: |
+2 -1
View File
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
@@ -81,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):
+7
View File
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
@@ -39,6 +44,8 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
Generated
+126
View File
@@ -428,6 +428,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
@@ -1114,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"
@@ -1266,6 +1300,7 @@ dependencies = [
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1349,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"
@@ -1434,10 +1476,25 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
"horizon-core",
"naga",
"zip",
]
@@ -1787,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"
@@ -1973,6 +2047,7 @@ dependencies = [
"naga-types",
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
@@ -2649,6 +2724,22 @@ dependencies = [
"zerovec",
]
[[package]]
name = "pp-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
dependencies = [
"unicode-xid",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -3114,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"
@@ -4197,6 +4297,7 @@ dependencies = [
"hashbrown 0.17.1",
"js-sys",
"log",
"naga",
"parking_lot",
"portable-atomic",
"profiling",
@@ -4238,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",
@@ -4253,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"
@@ -4275,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",
@@ -4301,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]]
@@ -5045,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"
+2 -2
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "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-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "xtask"]
resolver = "2"
[workspace.package]
+5
View File
@@ -16,6 +16,7 @@
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
+966
View File
@@ -0,0 +1,966 @@
# Horizon roadmap
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
## At a glance
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
**Being done now**
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
- **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; 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.
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
## Now
### Performance
#### Steady frames at long render distances
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster block lookups
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
### Modpacks
#### Modpacks' world generation, natively
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Servers and multiplayer
#### Playing on servers: everything shows as it should
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
</details>
#### Server dialogs and login screens
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
</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
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.
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
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
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.
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
</details>
### Rendering
#### Shader packs, written again from scratch
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
</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
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
</details>
### Code and tools
#### A leaner codebase that builds faster
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
## Next
### Performance
#### Fresh benchmarks against vanilla
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)). 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>
### Modpacks
#### CurseForge in the mod browser (coming soon)
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
<details><summary>Technical</summary>
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
</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
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
</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)
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
<details><summary>Technical</summary>
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
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.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
</details>
#### Wayland and Hyprland polish
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>
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
#### A complete video studio inside the game
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>
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>
## Help wanted
### Performance
#### Benchmark your computer
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
</details>
## 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)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
serde_json = "1.0.151"
[lints]
+281
View File
@@ -0,0 +1,281 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
pub fn json(&self) -> Value {
let requires: Vec<Value> = self
.facts
.requires
.iter()
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
.collect();
json!({
"kind": self.kind.name(),
"id": self.facts.id,
"version": self.facts.version,
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
pub fn text(&self) -> String {
let f = &self.facts;
let mut out = format!(
"{} {} ({}",
f.id.as_deref().unwrap_or("resource pack"),
f.version.as_deref().unwrap_or(""),
self.kind.name()
);
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
if let Some(game) = &f.game {
out += &format!(", for {game}");
}
let signed = if self.signed {
"signature verified"
} else {
"unsigned"
};
out += &format!(
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
}
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
_ => None,
}
}
/// Checks the file at `path` as `kind`; `key` and `signature` (the
/// `.minisig` text) check its signature.
pub fn check(
path: &Path,
kind: Kind,
key: Option<&str>,
signature: Option<&str>,
) -> Result<Report, String> {
let size = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.len();
if size > index::MAX_FILE {
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
}
index::open(&kind)?;
let facts = index::inspect(&kind, path)?;
let signed = match (key, signature) {
(Some(key), Some(sig)) => {
index::verify_signature(key, sig, path)?;
true
}
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
let mut report = Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
});
mods.tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
}
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
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) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-mods/tests/index")
.join(name)
}
#[test]
fn checks_each_kind_its_own_way() {
let pack = fixture("cozy-1.0.0.hpack");
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
assert_eq!(report.facts.requires[0].id, "coords_hud");
assert!(
report
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
let key = key.lines().last().unwrap();
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("didn't start"), "{e}");
let e = check(
&hmod,
Kind::Mod,
Some(key),
Some("untrusted comment: x\nRUQ=\n"),
)
.err()
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("shader packs open"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
+186 -21
View File
@@ -1,13 +1,21 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Manifest, Package,
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
mod check;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
@@ -28,7 +36,9 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -82,19 +92,68 @@ fn new(args: &[String]) -> Result<(), String> {
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
),
(
"data/{id}/horizon/block/glowing_amethyst.json",
include_str!("../template/content/block.json"),
),
(
"assets/{id}/blockstates/glowing_amethyst.json",
include_str!("../template/content/blockstate.json"),
),
(
"assets/{id}/models/block/glowing_amethyst.json",
include_str!("../template/content/model.json"),
),
(
"assets/{id}/items/glowing_amethyst.json",
include_str!("../template/content/item.json"),
),
(
"assets/{id}/lang/en_us.json",
include_str!("../template/content/lang.json"),
),
(
"data/{id}/loot_table/blocks/glowing_amethyst.json",
include_str!("../template/content/loot.json"),
),
(
"data/{id}/recipe/glowing_amethyst.json",
include_str!("../template/content/recipe.json"),
),
(
"data/minecraft/tags/block/mineable/pickaxe.json",
include_str!("../template/content/pickaxe.json"),
),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
let file = file.as_str();
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
let manifest =
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
Content::read(dir, &manifest)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
@@ -102,14 +161,53 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
build_in(Path::new("."), release)
}
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
return Err(format!(
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
));
};
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
let mut files = Vec::with_capacity(content.files.len());
for name in &content.files {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
cmd.current_dir(root).args([
"build",
"--lib",
"--target",
@@ -137,17 +235,39 @@ fn build(release: bool) -> Result<PathBuf, String> {
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
let file = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod check <file>")?;
let path = Path::new(file);
let kind = match flag(args, "--kind") {
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
None => check::kind_of(path)
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
};
let signature = flag(args, "--signature")
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()?;
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
if args.iter().any(|a| a == "--json") {
let json = match &report {
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
Err(e) => serde_json::json!({"ok": false, "error": e}),
};
println!("{json}");
}
let report = report?;
if !args.iter().any(|a| a == "--json") {
print!("{}", report.text());
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
@@ -198,4 +318,49 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
let path = dir.join("gems");
let new_args = [
path.display().to_string(),
"--template".into(),
"content".into(),
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path, true).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
let content = Content::read(&hmod, &package.manifest).unwrap();
assert!(
content
.files
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
assert!(
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
])
.is_err()
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+159
View File
@@ -0,0 +1,159 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
registry::{self, Sources},
};
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
}
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
} else if a == "--json" {
} else if a.starts_with("--") {
skip = true;
} else {
out.push(a.as_str());
}
}
out
}
fn lines(errors: Vec<String>) -> String {
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
}
pub fn main(args: &[String]) -> Result<(), String> {
let rest = args.get(1..).unwrap_or_default();
let pos = positional(rest);
match args.first().map(String::as_str) {
Some("build") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
let (Some(files), Some(out)) = (files, out) else {
return Err(USAGE.into());
};
let generated = flag(rest, "--generated")
.map(str::to_owned)
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
.unwrap_or_default();
let index = registry::build(&Sources {
registry: Path::new(checkout),
files: Path::new(files),
release: flag(rest, "--release").unwrap_or(FILE_HOST),
site: flag(rest, "--site").unwrap_or(SITE),
generated,
})
.map_err(lines)?;
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
println!(
"Wrote {out}: {} project(s), {} taken down",
index.projects.len(),
index.revoked.len()
);
Ok(())
}
Some("review") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
} else {
for n in &review.notes {
println!("note: {n}");
}
for e in &review.errors {
println!("error: {e}");
}
}
if review.errors.is_empty() {
Ok(())
} else {
Err(format!(
"{} change(s) the registry refuses",
review.errors.len()
))
}
}
// `check` (and its trial run) on each file `head` lists and `base`
// doesn't: a pull request's new files.
Some("check") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
let old: Vec<String> = match registry::load(Path::new(base)) {
Ok((entries, _)) => entries
.iter()
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
.collect(),
// The first pull request has no entries to compare with.
Err(_) => Vec::new(),
};
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
let mut failed = 0;
for e in &entries {
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
println!("{} {} ({}):", e.id, v.version, v.file);
let report = crate::check::check(
&files.join(&v.sha256),
e.kind.clone(),
e.key.as_deref(),
v.signature.as_deref(),
);
match report {
Ok(r) => print!("{}", r.text()),
Err(err) => {
failed += 1;
println!(" error: {err}");
}
}
}
}
if failed == 0 {
Ok(())
} else {
Err(format!("{failed} file(s) failed their check"))
}
}
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
Some("list") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
for e in &entries {
for v in &e.versions {
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
}
}
Ok(())
}
_ => Err(USAGE.into()),
}
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:amethyst_block",
"properties": { "hardness": 2.0, "light": 7 }
}
@@ -0,0 +1,5 @@
{
"variants": {
"": { "model": "{id}:block/glowing_amethyst" }
}
}
@@ -0,0 +1,10 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
capabilities = ["registries"]
@@ -0,0 +1,3 @@
{
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
}
@@ -0,0 +1,3 @@
{
"block.{id}.glowing_amethyst": "Glowing Amethyst"
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:block",
"pools": [
{
"condition": { "type": "minecraft:survives_explosion" },
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
"rolls": 1
}
],
"random_sequence": "{id}:blocks/glowing_amethyst"
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:block/cube_all",
"textures": { "all": "minecraft:block/amethyst_block" }
}
@@ -0,0 +1,3 @@
{
"values": ["{id}:glowing_amethyst"]
}
@@ -0,0 +1,6 @@
{
"type": "minecraft:crafting_shapeless",
"category": "building",
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
"result": { "id": "{id}:glowing_amethyst" }
}
+50 -15
View File
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
use horizon_core::random::RandomSource;
#[derive(Clone, Debug)]
enum Choice {
Single(Variant),
enum Choice<T> {
Single(T),
Weighted {
variants: Vec<Variant>,
variants: Vec<T>,
ends: Vec<i32>,
total: i32,
},
}
impl Choice {
impl Choice<Variant> {
fn new(value: &VariantSet) -> Result<Self> {
match value {
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
@@ -41,7 +41,23 @@ impl Choice {
}
}
}
fn select(&self, random: &mut impl RandomSource) -> &Variant {
}
impl<T> Choice<T> {
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
match self {
Self::Single(value) => Choice::Single(f(value)),
Self::Weighted {
variants,
ends,
total,
} => Choice::Weighted {
variants: variants.iter().map(&mut *f).collect(),
ends: ends.clone(),
total: *total,
},
}
}
fn select(&self, random: &mut impl RandomSource) -> &T {
match self {
Self::Single(value) => value,
Self::Weighted {
@@ -60,10 +76,11 @@ impl Choice {
}
/// Compile once per definition and share across its bound block states. Prefix
/// sums avoid rebuilding weight totals or scanning all entries during selection.
/// `map` swaps each variant for what it stands for (its baked model).
#[derive(Clone, Debug)]
pub struct CompiledChoices {
variants: Vec<Choice>,
multipart: Vec<Choice>,
pub struct CompiledChoices<T = Variant> {
variants: Vec<Choice<T>>,
multipart: Vec<Choice<T>>,
}
impl CompiledChoices {
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
@@ -82,10 +99,19 @@ impl CompiledChoices {
.collect::<Result<_>>()?,
})
}
}
impl<T> CompiledChoices<T> {
/// The same choices, with `f` of each variant in its place.
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
CompiledChoices {
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
let choice = match selected {
SelectedModels::Variant(index) => self.variants.get(*index)?,
SelectedModels::Multipart(_) => self.multipart.first()?,
@@ -95,14 +121,23 @@ impl CompiledChoices {
Choice::Weighted { variants, .. } => variants.first()?,
})
}
/// Append selected variants without allocating intermediate part lists. A
/// multipart root consumes nextLong even for an empty selected subset, then
/// resets the same random source to that seed before each selected part.
/// Append selected variants without allocating intermediate part lists.
pub fn collect<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
output: &mut Vec<&'a Variant>,
output: &mut Vec<&'a T>,
) -> Result<()> {
self.for_each(selected, random, |v| output.push(v))
}
/// Each selected variant, in order. A multipart root consumes nextLong
/// even for an empty selected subset, then resets the same random source
/// to that seed before each selected part.
pub fn for_each<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
mut f: impl FnMut(&'a T),
) -> Result<()> {
match selected {
SelectedModels::Variant(index) => {
@@ -110,7 +145,7 @@ impl CompiledChoices {
.variants
.get(*index)
.ok_or_else(|| error("invalid variant selection"))?;
output.push(choice.select(random));
f(choice.select(random));
}
SelectedModels::Multipart(indices) => {
if indices.iter().any(|index| *index >= self.multipart.len()) {
@@ -119,7 +154,7 @@ impl CompiledChoices {
let seed = random.next_long();
for index in indices {
random.set_seed(seed);
output.push(self.multipart[*index].select(random));
f(self.multipart[*index].select(random));
}
}
}
+25 -25
View File
@@ -173,6 +173,11 @@ impl Random {
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
@@ -329,7 +334,7 @@ fn read(
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
let send = |body: Vec<u8>| lock(&writer).send(&body);
let mut seen = HashSet::new();
let mut batch = BatchSize::new();
let mut teleports = 0u64;
@@ -338,7 +343,7 @@ fn read(
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
*lock(&bot.error) = Some(e.to_string());
}
return;
}
@@ -356,7 +361,7 @@ fn read(
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !bot.held.lock().unwrap().insert((x, z)) {
if !lock(&bot.held).insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
}
if seen.insert((x, z)) {
@@ -368,7 +373,7 @@ fn read(
cb::FORGET_LEVEL_CHUNK => {
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
play::Clientbound::decode(id, &body)
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
{
bot.forgotten.fetch_add(1, Ordering::Relaxed);
}
@@ -389,7 +394,7 @@ fn read(
}
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
let mut at = lock(&bot.teleport);
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
@@ -420,7 +425,7 @@ fn read(
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
@@ -455,7 +460,7 @@ fn run_bot(
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
*lock(&bot.error) = Some(e);
return;
}
};
@@ -469,12 +474,12 @@ fn run_bot(
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
if let Some(at) = *lock(&bot.teleport) {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
if lock(&bot.error).is_none() {
*lock(&bot.error) = Some("no spawn position within 60 s".into());
}
return;
}
@@ -482,7 +487,7 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
@@ -491,17 +496,15 @@ fn run_bot(
let mut target = pos;
let mut next = Instant::now();
loop {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
if let Some(at) = lock(&bot.teleport).take() {
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !bot.held.lock().unwrap().contains(&here) {
if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -558,7 +561,7 @@ fn run_bot(
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
lock(&writer).shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
@@ -612,13 +615,10 @@ fn main() {
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
};
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
@@ -636,10 +636,10 @@ fn main() {
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*until.lock().unwrap() = Some(Instant::now());
*lock(&until) = Some(Instant::now());
break;
}
}
@@ -669,7 +669,7 @@ fn main() {
"forgotten": b.forgotten.load(Ordering::Relaxed),
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
"error": *lock(&b.error),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
+8 -1
View File
@@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
@@ -66,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,15 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
"horizon.options.invert_y": "Invert Look Y",
"horizon.options.sneak_button": "Sneak Button",
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
}
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Геймпад",
"horizon.options.vibration": "Вибрация",
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
"horizon.options.lightbar": "Подсветка",
"horizon.options.look_sensitivity": "Чувствительность обзора",
"horizon.options.invert_x": "Инверсия обзора по X",
"horizon.options.invert_y": "Инверсия обзора по Y",
"horizon.options.sneak_button": "Кнопка приседания",
"horizon.options.move_deadzone": "Мёртвая зона движения",
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
}
@@ -0,0 +1,168 @@
//! What a column costs to load, on real terrain: generates and lights the
//! columns around a point with the vanilla generator, as a world being explored
//! does, then meshes every section of the inner columns on this one thread, so a
//! change to generation or meshing shows as time per column and per section.
//!
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
//! [--threads N] [--rounds 3]
//!
//! The default centre is the village of `tools/perf_bench.py`. The generation
//! figure is wall time with N workers (the game's own pool); the mesh figure is
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::HEIGHT,
light::LightTables,
mesher::{MeshTables, Mesher, Region, borrow},
models,
spans::thread_cpu,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
sync::Arc,
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error>;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1)?.parse().ok())
.unwrap_or(default)
}
fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
let seed: i64 = flag(&args, "--seed", 1234);
let radius: i32 = flag(&args, "--radius", 8);
let rounds: usize = flag(&args, "--rounds", 3);
let threads: usize = flag(
&args,
"--threads",
thread::available_parallelism().map_or(2, |n| n.get()),
);
let center = args
.iter()
.position(|a| a == "--center")
.map(|i| -> Result<(i32, i32), Error> {
Ok((
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
))
})
.transpose()?
.unwrap_or((-60, 10));
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let visibility = models::load_visibility(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
biomes: biomes.clone(),
assets: assets.clone(),
render,
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
streaming.radius = radius;
let mut world = World::new(BlockStateId(0), 0);
let started = Instant::now();
let mut quiet = 0;
while quiet < 100 {
let before = world.chunks.len();
let _ = streaming.update(&mut world);
thread::sleep(Duration::from_millis(2));
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
quiet + 1
} else {
0
};
}
let generated = started.elapsed() - Duration::from_millis(2 * 100);
let columns = world.chunks.len();
println!(
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
generated.as_secs_f64() * 1e3,
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
);
// Meshing every section of the columns whose neighbours are all loaded.
let inner = radius - 1;
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
.filter_map(|(x, z)| {
let region: Region = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.chunks
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.cloned()
})
});
region
.iter()
.flatten()
.all(Option::is_some)
.then_some(region)
})
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
.collect();
let mut best = Duration::MAX;
let (mut vertices, mut checksum) = (0, 0);
for _ in 0..rounds.max(1) {
let mut mesher = Mesher {
nether_shading: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility: context.tables.visibility.clone(),
};
vertices = 0;
let mut hash = DefaultHasher::new();
let mut spent = Duration::ZERO;
for (region, sy) in &regions {
let started = thread_cpu().ok_or("no thread CPU clock here")?;
let mesh = mesher.mesh(&borrow(region), *sy);
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
vertices += mesh.solid.len() + mesh.translucent.len();
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
}
checksum = hash.finish();
best = best.min(spent);
}
println!(
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
regions.len(),
best.as_secs_f64() * 1e3,
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
);
Ok(())
}
@@ -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;
@@ -85,6 +86,7 @@ mod sprint_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -132,6 +134,7 @@ probes! {
interaction_input_fixture,
lan_probe,
light_parity,
lore_tooltip_fixture,
marker_particles_probe,
menu_fixture,
mipmap_probe,
@@ -170,6 +173,7 @@ probes! {
team_rules_fixture,
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -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"),
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
let mut extra = String::new();
match kind {
"give" => count = book.add_recipes(&index, &list()).0,
"take" => count = book.remove_recipes(&index, &list()),
"take" => count = book.remove_recipes(&index, &list()).len(),
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
"set" => {
let i = match op["type"].as_str().unwrap_or_default() {
@@ -0,0 +1,37 @@
use horizon_client::text_display::TextDisplayData;
use horizon_protocol::{ids, packets::play::Clientbound};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let mut out = std::fs::File::create(&args[2])?;
for line in std::fs::read_to_string(&args[1])?.lines() {
let input: serde_json::Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
unreachable!()
};
let mut display = TextDisplayData::default();
display.apply(&packet.values);
if let Some(angles) = input["angles"].as_array() {
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
writeln!(
out,
"{}",
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
)?;
continue;
}
let mut state = serde_json::to_value(&display)?;
state["text"] = display.text.plain().into();
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
writeln!(out, "{}", state)?;
}
Ok(())
}
+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();
+12
View File
@@ -161,6 +161,9 @@ pub struct Audio {
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -595,6 +598,7 @@ impl Audio {
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -825,6 +829,14 @@ impl Audio {
/// and/or a source.
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
let event = event.map(crate::sound_events::normalize);
let selection = format!(
"{} {}",
source
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
.map_or("*", |s| s),
event.as_deref().unwrap_or("*")
);
*self.stopped.entry(selection).or_default() += 1;
self.engine
.stop_matching(event.as_deref(), source, &mut self.backend);
}
+243
View File
@@ -0,0 +1,243 @@
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
//! showing, in the order their add packets arrived.
use crate::{
hud_overlays::BossBar,
scoreboard::{comp_text, tag_to_json},
text::Text,
};
use horizon_protocol::packets::play::{BossAction, BossEvent};
/// `LerpingBossEvent.LERP_MILLISECONDS`.
const LERP_MS: u128 = 100;
/// `Util.getMillis`.
pub fn now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// One `LerpingBossEvent`.
#[derive(Clone, Debug)]
struct Event {
id: u128,
name: Text,
/// The progress the lerp started from (`progress`).
progress: f32,
/// `targetPercent`.
target: f32,
/// `setTime`.
set_at: u128,
color: u8,
overlay: u8,
darken: bool,
music: bool,
fog: bool,
}
impl Event {
/// `LerpingBossEvent.getProgress`.
fn displayed(&self, now: u128) -> f32 {
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
self.progress + (self.target - self.progress) * t
}
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
/// whatever it is showing now.
fn set_progress(&mut self, progress: f32, now: u128) {
self.progress = self.displayed(now);
self.target = progress;
self.set_at = now;
}
}
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
#[derive(Clone, Debug, Default)]
pub struct BossOverlay {
events: Vec<Event>,
/// `bossOverlayWorldDarkening` and the previous tick's value.
darken: f32,
darken_o: f32,
}
impl BossOverlay {
pub fn reset(&mut self) {
*self = Self::default();
}
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
/// handler. An id already shown keeps its place when added again.
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
// An ordinal past the enum shows as its last value.
let color = |c: i32| c.clamp(0, 6) as u8;
let overlay = |o: i32| o.clamp(0, 4) as u8;
let id = packet.id;
if let BossAction::Add {
name,
progress,
color: c,
overlay: o,
darken,
music,
fog,
} = &packet.action
{
let event = Event {
id,
name: comp_text(&tag_to_json(name)),
progress: *progress,
target: *progress,
set_at: now,
color: color(*c),
overlay: overlay(*o),
darken: *darken,
music: *music,
fog: *fog,
};
match self.events.iter_mut().find(|e| e.id == id) {
Some(slot) => *slot = event,
None => self.events.push(event),
}
return;
}
if let BossAction::Remove = packet.action {
self.events.retain(|e| e.id != id);
return;
}
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
return;
};
match &packet.action {
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
BossAction::UpdateStyle {
color: c,
overlay: o,
} => {
e.color = color(*c);
e.overlay = overlay(*o);
}
BossAction::UpdateProperties { darken, music, fog } => {
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
}
BossAction::Add { .. } | BossAction::Remove => {}
}
}
/// `GameRenderer.tick` while the level runs.
pub fn tick_darken(&mut self, running: bool) {
if !running {
return;
}
self.darken_o = self.darken;
if self.should_darken() {
self.darken = (self.darken + 0.05).min(1.0);
} else if self.darken > 0.0 {
self.darken = (self.darken - 0.0125).max(0.0);
}
}
/// `GameRenderer.bossOverlayWorldDarkening`.
pub fn darken_amount(&self, partial: f32) -> f32 {
let t = partial.clamp(0.0, 1.0);
self.darken_o + (self.darken - self.darken_o) * t
}
pub fn should_darken(&self) -> bool {
self.events.iter().any(|e| e.darken)
}
pub fn should_play_music(&self) -> bool {
self.events.iter().any(|e| e.music)
}
pub fn should_create_world_fog(&self) -> bool {
self.events.iter().any(|e| e.fog)
}
/// The bars `extractRenderState` walks, with the lerped progress.
pub fn bars(&self, now: u128) -> Vec<BossBar> {
self.events
.iter()
.map(|e| BossBar {
name: e.name.clone(),
progress: e.displayed(now),
color: e.color as usize,
overlay: e.overlay as usize,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
BossEvent {
id,
action: BossAction::Add {
name: Tag::String(name.into()),
progress,
color: 5,
overlay: 0,
darken,
music: false,
fog,
},
}
}
fn progress(id: u128, progress: f32) -> BossEvent {
BossEvent {
id,
action: BossAction::UpdateProgress { progress },
}
}
#[test]
fn progress_lerps_over_a_tenth_of_a_second() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 0.0, true, false), 0);
overlay.apply(&progress(1, 1.0), 0);
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
// A new target starts from the value on screen.
overlay.apply(&progress(1, 0.0), 50);
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
}
#[test]
fn darken_ramps_and_fades() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(1, "W", 1.0, true, false), 0);
overlay.tick_darken(true);
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
overlay.apply(
&BossEvent {
id: 1,
action: BossAction::Remove,
},
0,
);
overlay.tick_darken(true);
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
assert!(!overlay.should_play_music());
}
#[test]
fn bars_keep_arrival_order() {
let mut overlay = BossOverlay::default();
overlay.apply(&add(2, "b", 1.0, false, true), 0);
overlay.apply(&add(1, "a", 1.0, false, false), 0);
overlay.apply(&add(2, "c", 0.5, false, true), 0);
// An update for a bar this client never got is ignored.
overlay.apply(&progress(9, 0.5), 0);
let names: Vec<_> = overlay
.bars(0)
.into_iter()
.map(|b| b.name.plain())
.collect();
assert_eq!(names, ["c", "a"]);
assert!(overlay.should_create_world_fog());
overlay.apply(
&BossEvent {
id: 2,
action: BossAction::Remove,
},
0,
);
assert!(!overlay.should_create_world_fog());
}
}
+71 -37
View File
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
"notched_12",
"notched_20",
];
/// `BossBarColor.getFormatting`.
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
pub fn color_rgb(color: &str) -> u32 {
let name = match color {
"pink" => "red",
"red" => "dark_red",
"purple" => "dark_purple",
"purple" => "dark_blue",
other => other,
};
text::named_color(name).unwrap_or(text::WHITE)
}
/// A version-4 shaped id from a resource id. Saved boss events do not keep
/// their UUID (`CustomBossEvents` mints a new one on load).
pub fn stable_uuid(id: &str) -> u128 {
let mut h = 0xcbf29ce484222325u64;
for b in id.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x100000001b3);
}
let most = (h as i64) & -61441 | 16384;
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
#[derive(Clone, Debug, PartialEq)]
pub struct BossBar {
pub id: String,
/// The `ServerBossEvent` UUID. Not saved.
pub uuid: u128,
pub name: Comp,
pub color: String,
pub style: String,
pub value: i32,
pub max: i32,
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
/// a player is added.
pub visible: bool,
/// Whether the (single) local player is in the bar's player set.
pub player: bool,
pub darken: bool,
pub music: bool,
pub fog: bool,
/// `CustomBossEvent.players`, the UUIDs told about the bar.
pub players: Vec<[i32; 4]>,
}
impl BossBar {
pub fn new(id: &str, name: Comp) -> Self {
Self {
id: id.to_owned(),
uuid: stable_uuid(id),
name,
color: "white".into(),
style: "progress".into(),
value: 0,
max: 100,
visible: true,
player: false,
darken: false,
music: false,
fog: false,
players: Vec::new(),
}
}
/// `CustomBossEvent.getDisplayName`.
@@ -91,31 +115,34 @@ impl BossBars {
pub fn ids(&self) -> Vec<String> {
self.bars.iter().map(|b| b.id.clone()).collect()
}
/// The saved compound; the player's UUID stands for "the local player".
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
/// The saved compound (`CustomBossEvent.Packed`).
pub fn pack(&self) -> Tag {
let mut c = Compound::new();
for b in &self.bars {
let mut e = vec![
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(0)),
("PlayBossMusic", Tag::Byte(0)),
("CreateWorldFog", Tag::Byte(0)),
];
let players = match (b.player, player) {
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
_ => Vec::new(),
};
e.push(("Players", Tag::List(players)));
c.insert(b.id.as_str().into(), Tag::compound(e));
let players = b
.players
.iter()
.map(|u| Tag::IntArray(u.to_vec()))
.collect();
c.insert(
b.id.as_str().into(),
Tag::compound([
("Name", json_to_tag(&b.name)),
("Visible", Tag::Byte(i8::from(b.visible))),
("Value", Tag::Int(b.value)),
("Max", Tag::Int(b.max)),
("Color", Tag::String(b.color.as_str().into())),
("Overlay", Tag::String(b.style.as_str().into())),
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
("Players", Tag::List(players)),
]),
);
}
Tag::Compound(c)
}
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
pub fn unpack(data: &Tag) -> Self {
let mut bars = Vec::new();
if let Tag::Compound(c) = data {
for (id, e) in c {
@@ -130,21 +157,30 @@ impl BossBars {
Some(Tag::Byte(v)) => i32::from(*v),
_ => d,
};
let in_players = match (get("Players"), player) {
(Some(Tag::List(l)), Some(u)) => l
let players = match get("Players") {
Some(Tag::List(l)) => l
.iter()
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
_ => false,
.filter_map(|t| match t {
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let id = id.to_string_lossy();
bars.push(BossBar {
id: id.to_string_lossy(),
uuid: stable_uuid(&id),
id,
name: get("Name").map_or_else(|| literal(""), tag_to_json),
color: text("Color", "white"),
style: text("Overlay", "progress"),
value: num("Value", 0),
max: num("Max", 100),
visible: num("Visible", 0) != 0,
player: in_players,
darken: num("DarkenScreen", 0) != 0,
music: num("PlayBossMusic", 0) != 0,
fog: num("CreateWorldFog", 0) != 0,
players,
});
}
}
@@ -167,17 +203,15 @@ mod tests {
bar.value = 25;
bar.max = 50;
bar.visible = true;
bar.player = true;
let uuid = Some([1, 2, 3, 4]);
let back = BossBars::unpack(&b.pack(uuid), uuid);
bar.darken = true;
bar.players = vec![[1, 2, 3, 4]];
let back = BossBars::unpack(&b.pack());
assert_eq!(back, b);
// Another player's UUID does not include the local player.
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
let bar = b.get("test:x").unwrap();
assert_eq!(bar.progress(), 0.5);
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
assert_eq!(color_rgb("pink"), text::RED);
assert_eq!(color_rgb("purple"), 0xAA00AA);
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
b.remove("test:x");
assert!(b.bars.is_empty());
}
+4 -2
View File
@@ -55,7 +55,7 @@ impl CommandStorage {
.iter()
.map(|(k, v)| (k.as_str().into(), v.clone()))
.collect();
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&file(root, namespace),
Tag::compound([
(
@@ -64,7 +64,9 @@ impl CommandStorage {
),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("command storage {namespace} was not written: {e}");
}
}
}
/// `get`: an empty compound when unset.
+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);
@@ -0,0 +1,623 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use crate::command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
pub const DISCORD_APP_ID: &str = "1563857609738690560";
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
/// `spectator`) beside the logo; it doesn't yet.
const MODE_IMAGES: bool = false;
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
/// Discord drops activity updates beyond 5 in 20 seconds.
const BURST: usize = 5;
const WINDOW: Duration = Duration::from_secs(20);
/// Between two tries to reach Discord while it isn't running.
const RETRY: Duration = Duration::from_secs(15);
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
const REFRESH: Duration = Duration::from_secs(60);
const HANDSHAKE: u32 = 0;
const FRAME: u32 = 1;
const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host.
Hosting {
players: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// The player turned Rich Presence off: the activity is cleared.
Off,
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// What the game wants shown, and since when it has been in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Wanted {
status: Status,
/// Unix seconds: when the player entered the world (dimension changes keep it).
since: Option<u64>,
}
impl Wanted {
/// A new status at `now`; false if it is the one already wanted.
fn update(&mut self, status: Status, now: u64) -> bool {
if self.status == status {
return false;
}
let in_world = |s: Status| matches!(s, Status::World { .. });
if !in_world(status) {
self.since = None;
} else if !in_world(self.status) {
self.since = Some(now);
}
self.status = status;
true
}
}
struct Shared {
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
wanted: Wanted {
status: Status::Menus,
since: None,
},
stop: false,
stopped: false,
});
static STOPPED: Condvar = Condvar::new();
static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|| (automated && !fake_discord)
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
{
Ok(handle) => {
let _ = THREAD.set(handle.thread().clone());
}
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
/// On quit: clears the activity, waiting at most a moment for Discord.
pub fn stop() {
let Some(thread) = THREAD.get() else {
return;
};
let mut shared = lock();
shared.stop = true;
thread.unpark();
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
}
fn lock() -> std::sync::MutexGuard<'static, Shared> {
SHARED.lock().unwrap_or_else(|e| e.into_inner())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn run() {
let mut client: Option<Client> = None;
let mut sent: Option<(Wanted, Instant)> = None;
let mut last_try: Option<Instant> = None;
let mut limit = RateLimit::default();
loop {
let (wanted, stop) = {
let s = lock();
(s.wanted, s.stop)
};
if stop || wanted.status == Status::Off {
if let Some(mut c) = client.take() {
let _ = c.set_activity(None);
c.close();
}
sent = None;
if stop {
lock().stopped = true;
STOPPED.notify_all();
return;
}
std::thread::park();
continue;
}
let now = Instant::now();
if client.is_none() {
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
std::thread::park_timeout(wait);
continue;
}
last_try = Some(now);
client = Client::connect();
if client.is_none() {
continue;
}
}
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
if !due {
let (_, at) = sent.expect("sent");
std::thread::park_timeout(REFRESH - (now - at));
continue;
}
let wait = limit.wait(now);
if !wait.is_zero() {
std::thread::park_timeout(wait);
continue;
}
limit.record(now);
let c = client.as_mut().expect("connected");
match c.set_activity(activity(wanted.status, wanted.since)) {
Ok(()) => sent = Some((wanted, now)),
Err(e) => {
log::info!("Horizon: Discord presence disconnected ({e})");
client = None;
sent = None;
}
}
}
}
/// The activity Discord shows for `status`; `None` clears it.
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
let (details, state, party) = match status {
Status::Off => return None,
Status::Menus => ("In the menus", None, None),
Status::World {
play,
dimension,
mode,
} => {
if MODE_IMAGES {
let (key, text) = match mode {
GameMode::Survival => ("survival", "Survival"),
GameMode::Creative => ("creative", "Creative"),
GameMode::Adventure => ("adventure", "Adventure"),
GameMode::Spectator => ("spectator", "Spectator"),
};
assets["small_image"] = key.into();
assets["small_text"] = text.into();
}
let place = match dimension {
Dimension::Overworld => "In the Overworld",
Dimension::Nether => "In the Nether",
Dimension::End => "In the End",
};
let (details, party) = match play {
Play::Singleplayer => ("Singleplayer", None),
Play::Multiplayer => ("Multiplayer", None),
Play::Hosting { players } => ("Hosting a world", Some(players)),
};
(details, Some(place), party)
}
};
let mut a = json!({"details": details, "assets": assets});
if let Some(state) = state {
a["state"] = state.into();
}
if let Some(start) = since {
a["timestamps"] = json!({"start": start});
}
if let Some(players) = party {
let max = crate::lan::MAX_PLAYERS;
a["party"] = json!({
"id": format!("horizon-{}", std::process::id()),
"size": [players.clamp(1, max), max],
});
}
Some(a)
}
/// At most [`BURST`] updates in any [`WINDOW`].
#[derive(Default)]
struct RateLimit {
sent: VecDeque<Instant>,
}
impl RateLimit {
/// How long until the next update may go.
fn wait(&self, now: Instant) -> Duration {
if self.sent.len() < BURST {
return Duration::ZERO;
}
(self.sent[0] + WINDOW).saturating_duration_since(now)
}
fn record(&mut self, now: Instant) {
if self.sent.len() == BURST {
self.sent.pop_front();
}
self.sent.push_back(now);
}
}
fn encode(op: u32, payload: &Value) -> Vec<u8> {
let body = payload.to_string();
let mut frame = Vec::with_capacity(8 + body.len());
frame.extend_from_slice(&op.to_le_bytes());
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
frame.extend_from_slice(body.as_bytes());
frame
}
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
let mut header = [0; 8];
r.read_exact(&mut header)?;
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
if len > 1 << 20 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame too large",
));
}
let mut body = vec![0; len];
r.read_exact(&mut body)?;
Ok((op, serde_json::from_slice(&body)?))
}
trait Pipe: Read + Write + Send {}
impl<T: Read + Write + Send> Pipe for T {}
struct Client {
pipe: Box<dyn Pipe>,
nonce: u64,
}
impl Client {
/// The first Discord socket that answers the handshake with READY.
fn connect() -> Option<Self> {
sockets().into_iter().find_map(|path| {
let mut client = Client {
pipe: open(&path).ok()?,
nonce: 0,
};
match client.handshake() {
Ok(()) => {
log::info!("Horizon: connected to Discord for Rich Presence");
Some(client)
}
Err(e) => {
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
None
}
}
})
}
fn handshake(&mut self) -> io::Result<()> {
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
self.reply(|frame| frame["evt"] == "READY")
}
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
self.nonce += 1;
let nonce = self.nonce.to_string();
let command = json!({
"cmd": "SET_ACTIVITY",
"args": {"pid": std::process::id(), "activity": activity},
"nonce": nonce,
});
self.pipe.write_all(&encode(FRAME, &command))?;
self.reply(|frame| frame["nonce"] == nonce.as_str())
}
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
loop {
match read_frame(&mut self.pipe)? {
(FRAME, frame) if ours(&frame) => {
if frame["evt"] == "ERROR" {
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
}
return Ok(());
}
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
(CLOSE, frame) => {
return Err(io::Error::new(
io::ErrorKind::ConnectionAborted,
frame["message"].as_str().unwrap_or("closed").to_owned(),
));
}
_ => {}
}
}
}
fn close(mut self) {
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
}
}
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
fn sockets() -> Vec<PathBuf> {
sockets_with(|key| std::env::var_os(key), cfg!(windows))
}
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
if let Some(dir) = var(IPC_DIR_ENV) {
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
}
if windows {
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
}
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
.into_iter()
.filter_map(&var)
.map(PathBuf::from)
.collect();
dirs.push("/tmp".into());
dirs.dedup();
let mut paths = Vec::new();
for dir in dirs {
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
}
}
paths
}
#[cfg(unix)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let stream = std::os::unix::net::UnixStream::connect(path)?;
// A Discord that stops answering costs this thread a few seconds, never the game.
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
Ok(Box::new(stream))
}
#[cfg(windows)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let pipe = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)?;
Ok(Box::new(pipe))
}
#[cfg(test)]
mod tests {
use super::*;
fn world(play: Play, dimension: Dimension) -> Status {
Status::World {
play,
dimension,
mode: GameMode::Survival,
}
}
/// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its
/// Flatpak and Snap subfolders), named pipes on Windows.
#[test]
fn sockets_cover_where_discord_listens() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |key: &str| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| std::ffi::OsString::from(v))
}
};
let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false);
assert_eq!(
macos[0],
PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0")
);
assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9")));
assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0")));
let linux = sockets_with(
env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]),
false,
);
assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0"));
for path in [
"/run/user/1000/app/com.discordapp.Discord/discord-ipc-0",
"/run/user/1000/snap.discord/discord-ipc-0",
"/tmp/discord-ipc-0",
] {
assert!(linux.contains(&PathBuf::from(path)), "{path}");
}
assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once");
let bare = sockets_with(env(&[]), false);
assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0"));
let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true);
assert_eq!(windows.len(), 10);
// `Path::join` only uses `\` as the separator on Windows itself.
#[cfg(windows)]
assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0"));
let fake = sockets_with(
env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]),
false,
);
assert_eq!(fake.len(), 10);
assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0"));
}
#[test]
fn frames_round_trip() {
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
assert!(read_frame(&mut &frame[..6]).is_err());
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
assert!(read_frame(&mut huge.as_slice()).is_err());
}
#[test]
fn activities_say_what_the_player_does_and_nothing_private() {
assert_eq!(activity(Status::Off, None), None);
let menus = activity(Status::Menus, None).unwrap();
assert_eq!(menus["details"], "In the menus");
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
assert_eq!(menus["assets"]["large_image"], "horizon");
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
assert_eq!(single["details"], "Singleplayer");
assert_eq!(single["state"], "In the Nether");
assert_eq!(single["timestamps"]["start"], 1000);
assert!(single.get("party").is_none());
assert!(single["assets"].get("small_image").is_none());
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
assert_eq!(multi["details"], "Multiplayer");
assert_eq!(multi["state"], "In the End");
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
let host = activity(host, Some(5)).unwrap();
assert_eq!(host["details"], "Hosting a world");
assert_eq!(host["state"], "In the Overworld");
assert_eq!(host["party"]["size"], json!([3, 8]));
}
#[test]
fn time_counts_from_entering_the_world() {
let mut w = Wanted {
status: Status::Menus,
since: None,
};
assert!(!w.update(Status::Menus, 10));
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
assert_eq!(w.since, Some(20));
// Another dimension, opening to LAN: the same visit.
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
assert_eq!(w.since, Some(20));
assert!(w.update(Status::Menus, 50));
assert_eq!(w.since, None);
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
assert_eq!(w.since, Some(60));
assert!(w.update(Status::Off, 70));
assert_eq!(w.since, None);
}
#[test]
fn at_most_five_updates_in_twenty_seconds() {
let t = Instant::now();
let mut limit = RateLimit::default();
for i in 0..5 {
let now = t + Duration::from_secs(i);
assert_eq!(limit.wait(now), Duration::ZERO);
limit.record(now);
}
assert_eq!(
limit.wait(t + Duration::from_secs(5)),
Duration::from_secs(15)
);
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
limit.record(t + WINDOW);
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
}
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("discord-ipc-0");
let _ = std::fs::remove_file(&path);
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
let server = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let (op, hello) = read_frame(&mut s).unwrap();
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
s.write_all(&encode(FRAME, &ready)).unwrap();
let mut got = Vec::new();
for _ in 0..2 {
let (op, cmd) = read_frame(&mut s).unwrap();
assert_eq!(op, FRAME);
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
s.write_all(&encode(FRAME, &reply)).unwrap();
got.push(cmd);
}
got
});
let mut client = Client {
pipe: open(&path).unwrap(),
nonce: 0,
};
client.handshake().unwrap();
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
client.set_activity(shown.clone()).unwrap();
client.set_activity(None).unwrap();
let got = server.join().unwrap();
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
assert_eq!(got[0]["args"]["pid"], std::process::id());
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
assert_eq!(got[1]["args"]["activity"], Value::Null);
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
let _ = std::fs::remove_dir_all(dir);
}
}
+122 -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);
}
}
}
@@ -1155,6 +1165,72 @@ pub fn arrow_from_nbt(
Some((p, tag.get("inBlockState").cloned()))
}
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
pub fn fireball_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let power = match p.kind {
ProjectileKind::LargeFireball { power } => Some(power),
ProjectileKind::SmallFireball => None,
_ => return None,
};
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(
&mut tag,
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
Some(tag)
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
@@ -2139,3 +2215,41 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
}
#[cfg(test)]
mod fireball_nbt_tests {
use super::*;
#[test]
fn a_fireball_round_trips_its_flight() {
let raw = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[1.0, 70.0, -2.5])),
("Motion", doubles(&[0.0, -0.5, 1.0])),
("ExplosionPower", Tag::Byte(4)),
("acceleration_power", Tag::Double(0.0f64.to_bits())),
]);
let p = fireball_from_nbt(&raw, None).unwrap();
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
assert_eq!(number(back.get("ExplosionPower")), Some(4));
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
assert!(back.get("LeftOwner").is_none());
assert_eq!(number(back.get("HasBeenShot")), Some(0));
assert_eq!(
read_doubles::<3>(back.get("Motion")),
Some([0.0, -0.5, 1.0])
);
// A default fireball keeps vanilla's power and acceleration.
let plain = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
assert_eq!(
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
}
}
+19 -3
View File
@@ -1,7 +1,8 @@
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`.
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -112,6 +113,8 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -142,6 +145,7 @@ impl Default for Extras {
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
agent_control: false,
discord_presence: true,
}
}
}
@@ -207,6 +211,7 @@ impl Extras {
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
_ => {}
}
}
@@ -214,7 +219,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -246,6 +251,7 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
@@ -466,6 +472,8 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -546,6 +554,7 @@ impl Item {
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -559,7 +568,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
return None;
}
})
@@ -606,6 +615,9 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -641,6 +653,7 @@ impl Item {
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
_ => return None,
})
}
@@ -698,6 +711,7 @@ impl Item {
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::ZoomKey | Item::SortKey => {}
}
}
@@ -721,6 +735,7 @@ mod tests {
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -746,6 +761,7 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert_eq!(Extras::parse(&e.serialize()), e);
}
+29
View File
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
pub blindness: Option<(i32, bool)>,
/// The darkness effect's blend factor, while the effect is on.
pub darkness: Option<f32>,
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
pub darken_world: f32,
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let brightness = (1.0 - darkness) * (1.0 - darkness);
color = color.map(|c| c * brightness);
}
// FogRenderer.computeFogColor: a boss bar's world darkening.
if input.darken_world > 0.0 {
let t = input.darken_world;
color[0] += (color[0] * 0.7 - color[0]) * t;
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
@@ -394,6 +403,7 @@ mod tests {
skybox: true,
blindness: None,
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
}
}
@@ -422,6 +432,25 @@ mod tests {
assert_eq!(water.environmental_start, -8.0);
}
#[test]
fn boss_darkening_dims_fog_color() {
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
let sky = Sky {
fog_color: [1.0, 1.0, 1.0],
sky_color: [1.0, 1.0, 1.0],
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.skybox = false;
input.darken_world = 1.0;
let fog = setup_fog(&tables, &FogState::default(), &input);
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
}
#[test]
fn void_darkness_starts_32_above_the_floor() {
let sky = Sky {
fog_end: 1024.0,
+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()
+251 -80
View File
@@ -20,6 +20,7 @@ mod bossbars;
mod breezes;
mod cartography;
mod chat;
mod client_ambient;
mod clipboard;
mod combat;
mod command_blocks;
@@ -36,6 +37,7 @@ mod debug_keys;
mod decorated_pot;
mod dedicated;
mod deflect;
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -44,6 +46,7 @@ mod enchant_effects;
mod end;
mod entity_culling;
mod events;
mod execution_role;
mod explosion;
mod extras;
mod falling_damage;
@@ -140,6 +143,7 @@ mod sculk;
mod sculk_fixture;
mod server_packs;
mod server_travel;
mod shader_packs;
mod shield;
mod sign_edit;
mod sniffers;
@@ -154,6 +158,7 @@ mod storage;
mod structures;
mod subtitle_hud;
mod sulfur_cubes;
mod text_displays;
mod titles;
mod tnt;
mod totem;
@@ -290,8 +295,8 @@ struct Options {
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
/// What this Game executes; independent of its current server connection.
execution_role: execution_role::ExecutionRole,
}
impl Options {
/// The name to join servers with: the one given, else the offline name kept
@@ -348,7 +353,7 @@ impl Options {
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
execution_role: execution_role::ExecutionRole::Client,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
let mut args = std::env::args().skip(1);
@@ -362,6 +367,7 @@ impl Options {
return Ok(None);
}
"--dedicated" => {
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
o.dedicated = Some(args.by_ref().collect());
break;
}
@@ -477,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],
@@ -532,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,
@@ -1163,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.
@@ -1422,6 +1435,8 @@ struct Game<'a> {
dragon_model: Option<horizon_client::mobs::Part>,
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
text_display_vertices: usize,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
@@ -1561,11 +1576,15 @@ 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,
fns: functions::FnState,
bosses: bossbars::BossState,
/// `Hud.bossOverlay`: the bars this window is showing.
boss_overlay: horizon_client::boss_overlay::BossOverlay,
exec: commands::execute::ExecState,
rand_sequences: horizon_client::random_sequences::RandomSequences,
border: horizon_client::worldborder::WorldBorder,
@@ -1828,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
@@ -1854,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;
};
@@ -2164,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(),
@@ -2411,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,
@@ -2907,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);
@@ -4046,6 +4076,20 @@ impl Game<'_> {
self.capture_loaded_world();
Ok(())
}
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
/// farmland breaks. The local player's and every server player's landings.
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
use horizon_client::landing::FallEvent;
let (position, flags) = match *event {
FallEvent::Write {
position, flags, ..
} => (position, flags as i32),
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
_ => return,
};
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
self.tick_debug_keys()?;
self.tick_debug_charts();
@@ -4064,6 +4108,8 @@ impl Game<'_> {
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
// GameRenderer.tick: a wither's bar darkens the world, then fades.
self.boss_overlay.tick_darken(running);
if running {
self.world.game_time += 1;
if self.ui.rules.bool("advance_weather") {
@@ -4260,26 +4306,11 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
position,
flags,
..
}) => {
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, flags as i32)
});
}
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
position, ..
}) => {
self.with_level(|b, level, ticks| {
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
});
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
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,
@@ -4326,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]];
@@ -4360,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)
}
@@ -4438,7 +4482,10 @@ 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();
// Minecraft.handleKeybinds: held use repeats only while no item is in
// use (a raised shield or a drawn bow places and uses nothing).
if self.right
@@ -4819,11 +4866,15 @@ impl Game<'_> {
}
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
let _ = atlas_ptr;
// The ToastManager draws over every screen: a server's packs
// download while the connection screen shows.
if self.remote_overlay(&mut batch) {
self.draw_pack_toasts(&mut batch);
return batch;
}
if let Some(reason) = self.loading_reason() {
self.draw_level_loading(&mut batch, reason);
self.draw_pack_toasts(&mut batch);
return batch;
}
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
@@ -5032,9 +5083,11 @@ 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);
self.draw_pack_toasts(&mut batch);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
@@ -5288,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 };
@@ -5357,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);
}
}
}
@@ -5409,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(
@@ -5920,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 {
@@ -5937,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);
@@ -5961,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() {
@@ -6772,6 +6853,7 @@ impl Game<'_> {
let mut see_through = std::mem::take(&mut self.see_through_vertices);
see_through.clear();
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
@@ -7502,7 +7584,7 @@ impl Game<'_> {
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
(out, stars)
}
/// Load (or create) a world folder under saves/ and start streaming it.
/// Enter a local world from the client, selecting its simulation mode.
fn open_world(
&mut self,
folder: &str,
@@ -7521,20 +7603,16 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
let save_root = std::env::current_dir()?.join("saves").join(folder);
if integrated::wanted(&self.options, &save_root) {
if self
.options
.execution_role
.starts_integrated_server(self.options.server_thread)
{
return self.open_integrated(folder, name, seed, creative, create);
}
self.perf.world_opening();
let request = world_loading::WorldRequest {
save_root,
folder: folder.to_owned(),
name: name.map(str::to_owned),
seed,
creative,
create: create.cloned(),
};
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
if self.window.is_some() {
self.perf.world_opening();
if self.title.is_none() {
self.title = Some(Title::new(
std::env::current_dir()?.join("saves"),
@@ -7550,6 +7628,14 @@ impl Game<'_> {
)?);
return Ok(());
}
self.load_world(request)
}
/// Load authoritative world state synchronously, without client entry or
/// integrated-server selection. Server startup must use this entry point.
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
self.join_world_rest()?;
self.perf.world_opening();
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
self.finish_open_world(prepared)
}
@@ -7858,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] };
@@ -7963,6 +8053,8 @@ impl Game<'_> {
self.session_lock = None;
horizon_client::crash::clear_detail("World");
self.debug_charts.reset();
self.boss_overlay.reset();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
@@ -8745,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) {
@@ -9388,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;
@@ -9457,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),
@@ -9541,13 +9660,13 @@ impl Game<'_> {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let stack = self.inventory[c].clone();
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
if let Some(stack) = stack
&& self.cursor.0 >= x
if self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
hovered = Some(stack);
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = stack;
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -9570,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);
@@ -10064,6 +10188,11 @@ impl Game<'_> {
} else {
self.gather_dynamic_lights(camera, alpha);
}
let pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32)
};
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -10096,7 +10225,7 @@ impl Game<'_> {
if !self.settings.horizon.fog
&& !underwater
&& self.darkness_fog().is_none()
&& !self.dragon_bar_shown()
&& !self.boss_overlay.should_create_world_fog()
&& self.potions.effects.get("blindness").is_none()
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
{
@@ -10147,6 +10276,7 @@ impl Game<'_> {
hand: &hand,
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
screen: &screen,
pack: pack_inputs,
border,
game_time: self.world.game_time as f32,
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
@@ -10167,6 +10297,9 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
.darken_amount(self.clock.fraction() as f32),
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
@@ -10331,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
@@ -10557,7 +10691,7 @@ impl Game<'_> {
}
// The loading screen's chunk map fills ring by ring, as vanilla's.
self.streaming.loading =
self.options.dedicated.is_none() && self.loading_reason().is_some();
self.options.execution_role.is_client() && self.loading_reason().is_some();
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
if let Some(lod) = &mut self.lod {
@@ -10977,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);
}
@@ -11199,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()
}
_ => {}
}
}
@@ -11240,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, .. }
@@ -11394,6 +11535,7 @@ impl ApplicationHandler for Game<'_> {
self.track_toggle_screens();
// For the "I'm playing" ping: an atomic, sent on when it changes.
horizon_client::heartbeat::set_lan(self.is_published());
self.update_discord_presence();
let steps = self.clock.advance(elapsed);
// The world ticking in the frame: once its ticks took a
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
@@ -11676,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,
@@ -11713,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(),
@@ -11884,6 +12031,8 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
text_displays: Default::default(),
text_display_vertices: 0,
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
.unwrap_or_default()
@@ -11966,11 +12115,13 @@ 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(),
fns: Default::default(),
bosses: Default::default(),
boss_overlay: Default::default(),
exec: Default::default(),
rand_sequences: Default::default(),
border: Default::default(),
@@ -12062,6 +12213,8 @@ enum PendingWorldEntry {
Server {
address: String,
name: String,
/// A transfer's carried-over server cookies (`TransferState`).
cookies: Option<horizon_client::net::Cookies>,
},
}
@@ -12136,8 +12289,12 @@ impl Game<'_> {
}) => {
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
}
Some(PendingWorldEntry::Server { address, name }) => {
self.connect_server(&address, &name)?;
Some(PendingWorldEntry::Server {
address,
name,
cookies,
}) => {
self.connect_server_with_cookies(&address, &name, cookies)?;
}
None => {}
}
@@ -12244,7 +12401,7 @@ fn synchronous_startup(options: &Options) -> bool {
|| options.world_frames.is_some()
|| options.benchmark.is_some()
|| options.control.is_some()
|| options.dedicated.is_some()
|| !options.execution_role.is_client()
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
@@ -12689,6 +12846,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
@@ -12698,6 +12856,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
}
}
@@ -12785,7 +12944,7 @@ fn start_game<'a>(
game.sculk_fixture(&spec)?;
return Ok(None);
}
if game.options.dedicated.is_some() {
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
game.run_dedicated()?;
return Ok(None);
}
@@ -12860,6 +13019,7 @@ fn write_report(game: &Game<'_>) -> Result<()> {
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
EventLoop::new()?.run_app(&mut game)?;
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -13062,7 +13222,7 @@ impl Loading {
);
let renderer_ms = renderer_at.elapsed().as_millis();
// Audio is created on the main thread, after the core is ready.
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
@@ -13217,6 +13377,7 @@ fn run_loading(
};
let mut app = App::Loading(Box::new(loading));
EventLoop::new()?.run_app(&mut app)?;
horizon_client::discord_presence::stop();
match app {
App::Ready(mut game) => {
write_report(&game)?;
@@ -13237,13 +13398,15 @@ pub fn run() -> Result<()> {
let Some(mut options) = Options::read()? else {
return Ok(());
};
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
if options.execution_role == execution_role::ExecutionRole::DedicatedServer
&& !dedicated::preflight(&mut options)?
{
return Ok(());
}
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
if options.dedicated.is_none() {
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
@@ -13253,7 +13416,7 @@ pub fn run() -> Result<()> {
}
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
// server, a benchmark or a run that quits by itself.
if options.dedicated.is_none()
if options.execution_role.is_client()
&& options.benchmark.is_none()
&& !options.offscreen
&& options.frames.is_none()
@@ -13265,6 +13428,14 @@ pub fn run() -> Result<()> {
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -13294,7 +13465,7 @@ pub fn run() -> Result<()> {
)?;
let gui = loaded.gui.ok_or("missing menu bundle")?;
options.resources = loaded.root.clone();
let audio = if options.dedicated.is_some() {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
+131 -29
View File
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
let Some(d) = &a.display else {
return Text::literal(a.id.clone());
};
let color = match d.frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
};
// Advancement.decorateName: the title in the frame's colour (styles
// merged into it), a new line and the description.
let coloured = text_of(&d.title).with_color(color);
let mut component = coloured.component();
if let Some(o) = component.as_object_mut() {
o.insert(
"extra".into(),
serde_json::json!(["\n", text_of(&d.description).component()]),
);
}
let tooltip = coloured
.append("\n")
.append(text_of(&d.description))
.with_component(component);
let color = frame_color(d.frame);
let title = text_of(&d.title).with_style(&Style {
hover: Some(Box::new(tooltip)),
hover: Some(Box::new(decorate_name(d))),
..Style::default()
});
Text::translate("chat.square_brackets", &[title]).with_color(color)
}
fn frame_color(frame: Frame) -> u32 {
match frame {
Frame::Challenge => text::DARK_PURPLE,
_ => text::GREEN,
}
}
/// `Advancement.decorateName`: a copy of the title with the frame's colour
/// under its own style (mergeStyles), then a new line and the description
/// after the title's own siblings, so both inherit the title's root style.
/// Built on the title's JSON (a list as its first element with the rest as
/// siblings): a parsed Text moves a styled root's style down a level once it
/// has siblings.
fn decorate_name(d: &adv::Display) -> Text {
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
Value::String(s) => serde_json::json!({ "text": s }),
c => c,
};
if let Some(o) = component.as_object_mut() {
let frame = Text::empty().with_color(frame_color(d.frame)).component();
o.entry("color").or_insert_with(|| frame["color"].clone());
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
if let Some(extra) = extra.as_array_mut() {
extra.extend(["\n".into(), d.description.clone()]);
}
}
text_of(&component).with_component(component)
}
impl Game<'_> {
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
/// its criteria): false when nothing changed.
@@ -1051,17 +1061,10 @@ impl Game<'_> {
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
Text::translate(key, &[])
} else if let Some(s) = v["text"].as_str() {
Text::literal(s)
} else if let Some(s) = v.as_str() {
Text::literal(s)
} else {
Text::empty()
}
horizon_client::scoreboard::comp_text(v)
}
#[cfg(test)]
@@ -1095,6 +1098,105 @@ mod tests {
(seen, None)
}
#[test]
fn titles_keep_their_siblings_and_styles() {
let title = text_of(&serde_json::json!({
"text": "",
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
}));
assert_eq!(title.plain(), "Quest Board");
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
assert_eq!(title.spans[1].style.color, None);
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
assert_eq!(
vanilla,
Text::translate("advancements.story.root.title", &[])
);
assert_eq!(
vanilla.translation_key(),
Some("advancements.story.root.title")
);
}
fn announced(title: Value, description: Value) -> adv::Advancement {
adv::Advancement {
id: "quests:board".into(),
parent: None,
display: Some(adv::Display {
icon: "minecraft:book".into(),
title,
description,
frame: Frame::Task,
background: None,
show_toast: true,
announce: true,
hidden: false,
}),
criteria: Vec::new(),
requirements: Vec::new(),
rewards: adv::Rewards::default(),
}
}
/// The chat hover: the JSON a client receives, and the spans drawn here.
fn hover(a: &adv::Advancement) -> (Value, Text) {
let local = decorate_name(a.display.as_ref().unwrap());
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
(sent, local)
}
#[test]
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
let a = announced(
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
serde_json::json!("Open the board"),
);
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
assert_eq!(colors(&sent), [gold, green, green, green]);
// The spans drawn here come from the same JSON a client receives.
assert_eq!(colors(&local), colors(&sent));
}
#[test]
fn chat_hover_description_takes_a_styled_titles_own_style() {
// mergeStyles keeps the title's gold; the description inherits it.
let object = announced(
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
serde_json::json!("Open the board"),
);
let list = announced(
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
serde_json::json!("Open the board"),
);
for a in [object, list] {
let (sent, local) = hover(&a);
let sent = horizon_client::scoreboard::comp_text(&sent);
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
let last = &sent.spans.last().unwrap().style;
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
assert_eq!(local.spans.last().unwrap().style, *last);
}
}
#[test]
fn chat_hover_of_a_vanilla_advancement() {
let a = announced(
serde_json::json!({"translate": "advancements.story.root.title"}),
serde_json::json!({"translate": "advancements.story.root.description"}),
);
assert_eq!(
hover(&a).0,
serde_json::json!({
"translate": "advancements.story.root.title",
"color": "green",
"extra": ["\n", {"translate": "advancements.story.root.description"}],
})
);
}
#[test]
fn toasts_slide_in_squared_and_out_after_five_seconds() {
let mut toast = recipes(1);
+9 -3
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> {
@@ -469,7 +470,8 @@ impl Game<'_> {
"flying": p.flying,
"fall_flying": p.fall_flying,
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"sprinting": p.sprinting,
"fov_modifier": self.view.fov_modifier,
"health": p.health.health(),
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
@@ -1598,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;
+370 -52
View File
@@ -1,22 +1,29 @@
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
use super::{
Game,
commands::{R, err},
Dim, Game,
commands::{R, Target, err},
};
use horizon_client::{
boss_overlay,
bossbar::{self, BossBars},
command::{Arg, Context},
hud_overlays, save,
scoreboard::{self as sb, Comp, comp_text},
mobs::Species,
save,
scoreboard::{self as sb, Comp, json_to_tag},
scoreboard_cmd::format_list,
text::Text,
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct BossState {
pub bars: BossBars,
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
}
fn num(n: impl std::fmt::Display) -> Text {
@@ -36,7 +43,7 @@ impl Game<'_> {
if let Some(root) = save::read_dat(&self.bossbar_path())
&& let Some(data) = root.get("data")
{
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
self.bosses.bars = BossBars::unpack(data);
}
self.ui.registries.bossbars = self.bosses.bars.ids();
}
@@ -47,13 +54,15 @@ impl Game<'_> {
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
return;
}
let _ = save::write_dat(
if let Err(e) = save::write_dat(
&self.bossbar_path(),
Tag::compound([
("data", self.bosses.bars.pack(self.level.player_uuid)),
("data", self.bosses.bars.pack()),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]),
);
) {
log::warn!("boss bars were not written: {e}");
}
}
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
self.bosses.bars.get(id).is_some()
@@ -68,6 +77,7 @@ impl Game<'_> {
b.value = b.value.min(b.max);
}
}
self.publish_boss_bars();
}
fn bar_id(ctx: &Context) -> String {
match ctx.get("id") {
@@ -86,6 +96,7 @@ impl Game<'_> {
self.load_bossbars();
let result = self.bossbar_command(id, ctx);
self.ui.registries.bossbars = self.bosses.bars.ids();
self.publish_boss_bars();
result
}
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
@@ -97,6 +108,10 @@ impl Game<'_> {
}
let name = self.component_json(ctx, "name")?;
self.bosses.bars.add(&bar, name);
if let Some(b) = self.bosses.bars.get_mut(&bar) {
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
b.uuid = insecure_uuid(&mut self.world.random);
}
let shown = self
.bosses
.bars
@@ -173,19 +188,20 @@ impl Game<'_> {
i32::from(b.visible)
}
_ => {
if b.player {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(1), format_list(vec![name])],
));
} else {
let names = self.bossbar_player_names(&b.players);
let n = names.len();
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.get.players.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.get.players.some",
&[s, num(n), format_list(names)],
));
}
i32::from(b.player)
n as i32
}
})
}
@@ -295,31 +311,46 @@ impl Game<'_> {
Ok(0)
}
_ => {
// players [<targets>]
let include = if ctx.get("targets").is_some() {
!self.entities(ctx, "targets", true)?.is_empty()
// players [<targets>]: replace the set. No argument clears it.
let targets = if ctx.get("targets").is_some() {
self.entities(ctx, "targets", true)?
} else {
false
Vec::new()
};
let mut uuids = Vec::new();
let mut names = Vec::new();
for t in &targets {
let Some(uuid) = self.player_target_uuid(*t) else {
continue;
};
if !uuids.contains(&uuid) {
uuids.push(uuid);
names.push(self.target_name(*t));
}
}
let b = self.bosses.bars.get_mut(bar).expect("checked");
if b.player == include {
let mut had = b.players.clone();
had.sort();
let mut next = uuids.clone();
next.sort();
if had == next {
return Err(err("commands.bossbar.set.players.unchanged", &[]));
}
b.player = include;
b.players = uuids;
let n = b.players.len();
let s = shown(self);
if include {
let name = self.target_name(super::commands::Target::Player);
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(1), format_list(vec![name])],
));
} else {
if n == 0 {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.none",
&[s],
));
} else {
self.feedback(Text::translate(
"commands.bossbar.set.players.success.some",
&[s, num(targets.len()), format_list(names)],
));
}
Ok(i32::from(include))
Ok(n as i32)
}
}
}
@@ -333,29 +364,316 @@ impl Game<'_> {
Ok(self.resolve_component(value))
}
// ---- drawing ------------------------------------------------------------------
/// The visible custom bars (`/bossbar`), after the built-in ones.
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for b in self
.bosses
.bars
.bars
.iter()
.filter(|b| b.visible && b.player)
{
bars.push(hud_overlays::BossBar {
name: comp_text(&b.name),
progress: b.progress(),
color: bossbar::COLORS
/// Names of the online players in a boss bar's set, in join order.
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
self.known_boss_players()
.into_iter()
.filter(|(uuid, _)| players.contains(uuid))
.map(|(_, name)| name)
.collect()
}
/// The players who can be told about a bar: the one in place, then guests.
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
let mut out = Vec::new();
if self.player.is_some() {
out.push((self.acting_uuid(), self.target_name(Target::Player)));
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
if out.iter().any(|(uuid, _)| *uuid == ints) {
continue;
}
out.push((ints, Text::literal(g.name.clone())));
}
}
out
}
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
match target {
Target::Player => Some(self.acting_uuid()),
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.position(|c| *c == b.color)
.unwrap_or(0),
overlay: bossbar::STYLES
.iter()
.position(|s| *s == b.style)
.unwrap_or(0),
});
.find(|g| g.conn == conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
}),
_ => None,
}
}
// ---- ServerBossEvent delivery ------------------------------------------------
/// Tell every viewer the boss events they should be seeing. The window
/// that simulates the world applies them to its own overlay; everyone
/// else gets `ClientboundBossEventPacket`s.
pub(super) fn publish_boss_bars(&mut self) {
if self.remote.is_some() {
return;
}
self.load_bossbars();
let now = boss_overlay::now_millis();
let viewers = self.boss_viewers();
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
self.bosses.sent.retain(|id, _| live.contains(id));
let mut packets = Vec::new();
for viewer in &viewers {
let desired = self.boss_events_for(viewer);
for event in self.diff_boss_events(viewer, desired) {
if viewer.local {
self.boss_overlay.apply(&event, now);
} else if let Some(conn) = viewer.conn {
packets.push((conn, C::BossEvent(event)));
}
}
}
if packets.is_empty() {
return;
}
let Some(host) = self.lan.as_ref() else {
return;
};
for (conn, packet) in &packets {
host.hub.send_to(*conn, packet);
}
}
fn boss_viewers(&self) -> Vec<Viewer> {
let mut out = Vec::new();
// While `as_guest` has swapped a guest in, the host's own position
// is not in place. Their overlay updates once the tick puts it back.
let local =
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
if local && let Some(p) = &self.player {
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
out.push(Viewer {
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
ints,
at: [p.position.x, p.position.y, p.position.z],
alive: !p.health.dead_or_dying(),
here: true,
local: true,
conn: None,
view_chunks: self.settings.render_distance,
});
}
if let Some(host) = &self.lan {
for g in host.hub.guests.iter().filter(|g| g.placed) {
if out.iter().any(|v| v.uuid == g.uuid) {
continue;
}
out.push(Viewer {
uuid: g.uuid,
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
at: g.position,
alive: !g.dead,
here: g.dimension == self.dim.id(),
local: false,
conn: Some(g.conn),
view_chunks: g.view_distance,
});
}
}
out
}
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
/// joins), then the dragon, withers and the raid.
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
let mut out = Vec::new();
for b in &self.bosses.bars.bars {
if !b.visible || !b.players.contains(&viewer.ints) {
continue;
}
out.push(Offer {
id: b.uuid,
name: json_to_tag(&b.name),
progress: b.progress(),
color: color_index(&b.color),
overlay: style_index(&b.style),
darken: b.darken,
music: b.music,
fog: b.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid("minecraft:ender_dragon"),
name: translate_tag("entity.minecraft.ender_dragon"),
progress,
color: 0,
overlay: 0,
darken: false,
music: true,
fog: true,
scope: Scope::Level(self.dim),
});
}
if viewer.here {
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
for m in self
.mobs
.iter()
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
{
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
if dx * dx + dz * dz > range * range {
continue;
}
let progress = if m.extra.counter > 0 {
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
} else {
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
};
let name = if let Some(text) = &m.custom_name_text {
json_to_tag(&text.component())
} else if let Some(name) = &m.custom_name {
Tag::String(name.as_str().into())
} else {
translate_tag("entity.minecraft.wither")
};
out.push(Offer {
id: super::lan::entity_uuid(m.id),
name,
progress,
color: 5,
overlay: 0,
darken: true,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
}
if viewer.here
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
{
out.push(Offer {
id: bossbar::stable_uuid(&format!("raid:{id}")),
name,
progress,
color: 2,
overlay: 2,
darken: false,
music: false,
fog: false,
scope: Scope::Level(self.dim),
});
}
out
}
/// The packets that take `viewer` from what it was last sent to `desired`
/// (`ServerBossEvent`'s broadcasts).
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
let full = viewer.local;
let dim = self.dim;
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
let mut out = Vec::new();
let event = |id, action| play::BossEvent { id, action };
sent.retain(|id, prev| {
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
let keep = !owned || desired.iter().any(|e| e.id == *id);
if !keep {
out.push(event(*id, BossAction::Remove));
}
keep
});
for offer in desired {
let Some(prev) = sent.get(&offer.id) else {
out.push(event(
offer.id,
BossAction::Add {
name: offer.name.clone(),
progress: offer.progress,
color: offer.color,
overlay: offer.overlay,
darken: offer.darken,
music: offer.music,
fog: offer.fog,
},
));
sent.insert(offer.id, offer);
continue;
};
let id = offer.id;
if prev.progress != offer.progress {
let progress = offer.progress;
out.push(event(id, BossAction::UpdateProgress { progress }));
}
if prev.name != offer.name {
let name = offer.name.clone();
out.push(event(id, BossAction::UpdateName { name }));
}
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
let (color, overlay) = (offer.color, offer.overlay);
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
}
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
out.push(event(
id,
BossAction::UpdateProperties { darken, music, fog },
));
}
sent.insert(id, offer);
}
out
}
/// `ClientPacketListener.handleBossUpdate`.
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
self.boss_overlay.apply(packet, boss_overlay::now_millis());
}
}
/// `Mth.createInsecureUUID`.
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
let most = random.next_long() & -61441 | 16384;
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
((most as u64 as u128) << 64) | (least as u64 as u128)
}
fn translate_tag(key: &str) -> Tag {
Tag::compound([("translate", Tag::String(key.into()))])
}
fn color_index(color: &str) -> i32 {
bossbar::COLORS
.iter()
.position(|c| *c == color)
.unwrap_or(6) as i32
}
fn style_index(style: &str) -> i32 {
bossbar::STYLES
.iter()
.position(|s| *s == style)
.unwrap_or(0) as i32
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Global,
Level(Dim),
}
struct Viewer {
uuid: u128,
ints: [i32; 4],
at: [f64; 3],
alive: bool,
here: bool,
local: bool,
conn: Option<u32>,
view_chunks: i32,
}
/// A boss event as one viewer should see it, and as it was last sent.
struct Offer {
id: u128,
name: Tag,
progress: f32,
/// `BossBarColor` and `BossBarOverlay` ordinals.
color: i32,
overlay: i32,
darken: bool,
music: bool,
fog: bool,
scope: Scope,
}
+17 -5
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<'_> {
@@ -570,8 +572,14 @@ impl Game<'_> {
/// ChatScreen.handleChatInput: history, then a command or a chat line.
pub(super) fn handle_chat_input(&mut self, message: &str) {
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it.
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
if self.remote.is_some() {
if let Some(command) = message.strip_prefix('/')
&& self.mod_command(command)
{
return;
}
self.send_chat(message);
return;
}
@@ -580,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());
@@ -816,8 +824,12 @@ impl Game<'_> {
("data", data),
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
]);
let _ = horizon_client::save::write_dat(&path, root);
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -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 {
+35 -24
View File
@@ -42,7 +42,8 @@ pub(super) enum Target {
Mob(u32),
Item(u32),
Orb(usize),
Arrow(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -213,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);
@@ -327,7 +328,7 @@ impl Game<'_> {
.orbs
.get(i)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Arrow(i) => self
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
@@ -382,7 +383,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Arrow(_) => "minecraft:arrow".into(),
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
@@ -439,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
@@ -503,6 +508,7 @@ impl Game<'_> {
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
@@ -517,8 +523,12 @@ impl Game<'_> {
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| p.arrow.is_some())
.map(|(i, _)| Target::Arrow(i)),
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
@@ -1082,6 +1092,8 @@ impl Game<'_> {
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
}
Target::Player => {
// ServerPlayer.teleport: removeVehicle before placing the player.
self.stop_riding();
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1098,9 +1110,15 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = v;
m.previous = v;
if let Some([yaw, _]) = rotation {
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
// (clamped to -90..90), then setOldRot.
if let Some([yaw, pitch]) = rotation {
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
m.head_pitch = pitch.clamp(-90.0, 90.0);
m.previous_yaw = m.yaw;
m.previous_head_pitch = m.head_pitch;
}
}
}
@@ -1116,7 +1134,7 @@ impl Game<'_> {
o.previous = v;
}
}
Target::Arrow(i) => {
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
a.position = v;
a.previous = v;
@@ -1540,14 +1558,10 @@ impl Game<'_> {
self.orbs[i].age = u32::MAX / 2;
}
}
Target::Arrow(i) => {
if let Some(a) = self
.creatures
.projectiles
.get_mut(i)
.and_then(|p| p.arrow.as_mut())
{
a.removed = true;
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
@@ -1569,7 +1583,7 @@ impl Game<'_> {
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Arrow(i) => *i,
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
@@ -1577,9 +1591,6 @@ impl Game<'_> {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
self.creatures
.projectiles
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -2798,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]),
@@ -2808,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()
@@ -65,9 +65,10 @@ impl Game<'_> {
tag
}
Target::Mob(id) => {
let uuid = self.uuid_for(id);
let m = self.mobs.iter().find(|m| m.id == id)?;
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
// `saveWithoutId` writes the entity as saved, riders under
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
let i = self.mobs.iter().position(|m| m.id == id)?;
self.mob_tree_nbt(i)
}
Target::Item(id) => {
let uuid = self.uuid_for(id);
@@ -113,7 +114,7 @@ impl Game<'_> {
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Arrow(i) => {
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
@@ -125,7 +126,8 @@ impl Game<'_> {
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
})
}
@@ -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]
@@ -59,7 +59,15 @@ impl Game<'_> {
self.recipe_unlocked(&unlocked);
count as i32
} else {
self.level.recipe_book.remove_recipes(index, &recipes) as i32
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
tracker.track(&h, n);
}
@@ -345,7 +353,26 @@ impl Game<'_> {
Some(Arg::Resource(s)) => Some(s.clone()),
_ => None,
};
self.audio.stop_sounds(sound.as_deref(), source);
// StopSoundCommand: each target's connection gets the packet; the
// window's own player stops its sounds.
let packet = C::StopSound(play::StopSound {
source: source.map(|s| s as i32),
sound: sound.clone(),
});
for &t in &targets {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
let key = match (source, &sound) {
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
(Some(_), None) => "commands.stopsound.success.source.any",
@@ -1047,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],
@@ -1067,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()],
@@ -1080,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;
@@ -1111,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> {
+17 -2
View File
@@ -226,6 +226,16 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
}
"section-sides" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
None => {}
Some("on") => r.set_section_sides(true),
Some("off") => r.set_section_sides(false),
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
}
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"status" => Ok(Some(self.control_status())),
"pad" => Ok(Some(self.pad_command(&words)?)),
@@ -503,7 +513,10 @@ impl Game<'_> {
if self.pending_screenshot.is_some() {
return Err("a screenshot is already pending".into());
}
renderer.screenshot = Some(arg(1)?.into());
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
@@ -556,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(
@@ -786,6 +799,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
@@ -981,6 +995,7 @@ impl Game<'_> {
status["destroying"] = json!(destroying);
status["physical_keys"] = json!(physical_keys);
status["heard"] = json!(self.audio.heard);
status["stopped_sounds"] = json!(self.audio.stopped);
// Boats, minecarts, armor stands, item frames and paintings.
status["objects"] = json!(self.objects_status());
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
+38 -3
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() })
@@ -978,7 +982,9 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::Projectile(p) => {
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
@@ -1556,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);
@@ -1692,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
+3 -1
View File
@@ -185,7 +185,9 @@ impl Game<'_> {
// Minecraft.emergencySaveAndCrash: the world is saved, then the
// game stops; main writes the crash report.
let crash = horizon_client::crash::DebugCrash::new();
let _ = self.save_game();
if let Err(e) = self.save_game() {
log::error!("the emergency save before the crash failed: {e}");
}
return Err(crash.into());
}
}
+14 -13
View File
@@ -6,7 +6,7 @@
//! its simulation and the play-state hub that LAN hosting uses, configured
//! from server.properties, with the operator, whitelist and ban lists and a
//! console on standard input.
use super::{Game, Options, lan::LanSettings};
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
use horizon_client::{
Result,
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
@@ -218,21 +218,22 @@ impl Lists {
use_whitelist: false,
enforce_whitelist: false,
};
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
if let Err(e) = r {
log("WARN", &format!("Failed to load {what}: {e}"));
log("WARN", &format!("Failed to {what}: {e}"));
}
};
// DedicatedPlayerList's constructor order.
warn("user banlist", l.bans.load());
let _ = l.bans.save();
warn("ip banlist", l.ip_bans.load());
let _ = l.ip_bans.save();
warn("operators list", l.ops.load());
warn("white-list", l.white.load());
let _ = l.ops.save();
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
warn("load user banlist", l.bans.load());
warn("save user banlist", saved(l.bans.save()));
warn("load ip banlist", l.ip_bans.load());
warn("save ip banlist", saved(l.ip_bans.save()));
warn("load operators list", l.ops.load());
warn("load white-list", l.white.load());
warn("save operators list", saved(l.ops.save()));
if !l.white.file.exists() {
let _ = l.white.save();
warn("save white-list", saved(l.white.save()));
}
l
}
@@ -546,13 +547,13 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
self.open_world(
self.load_world(WorldRequest::new(
&folder,
Some(&level_name),
Some(props.seed),
game_mode == 1,
None,
)?;
)?)?;
if new_world {
self.level.hardcore = props.hardcore;
}
+43
View File
@@ -0,0 +1,43 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
+43 -35
View File
@@ -12,7 +12,6 @@ use horizon_client::{
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -337,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 {
@@ -868,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() {
@@ -963,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)
@@ -2157,23 +2164,12 @@ impl Game<'_> {
});
}
}
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
/// raids' and the custom bars, in that order.
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
/// (or a server's packets) put on this client's overlay.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
let mut bars = Vec::new();
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
bars.push(hud_overlays::BossBar {
name: Text::literal(
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
),
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
color: 0,
overlay: 0,
});
}
self.wither_bars(&mut bars);
self.raid_bars(&mut bars);
self.custom_bossbars(&mut bars);
let bars = self
.boss_overlay
.bars(horizon_client::boss_overlay::now_millis());
let draws = hud_overlays::boss_overlay(
batch.width as i32,
batch.height as i32,
@@ -2182,14 +2178,26 @@ impl Game<'_> {
);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
/// with `setCreateWorldFog`).
pub(super) fn dragon_bar_shown(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some()
/// The dragon fight's bar for a player at `at`
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
/// and the player is alive within 192 blocks of (0, 128, 0)).
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
return None;
}
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
if !near {
return None;
}
Some(
self.dragon
.as_ref()
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
)
}
/// `music.dragon` while the boss bar plays music.
/// `BossHealthOverlay.shouldPlayMusic`.
pub(super) fn boss_music(&self) -> bool {
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
self.boss_overlay.should_play_music()
}
}
pub(super) struct DragonFireball {
@@ -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 {
@@ -0,0 +1,39 @@
//! A Game's execution role is fixed at construction. Connecting or disconnecting
//! changes `remote`, not this role. A client may simulate a world in-frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ExecutionRole {
Client,
IntegratedServer,
DedicatedServer,
}
impl ExecutionRole {
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
pub fn is_client(self) -> bool {
self == Self::Client
}
/// The optional in-frame simulation remains a client execution mode.
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
self.is_client() && server_thread
}
}
#[cfg(test)]
mod tests {
use super::ExecutionRole::*;
#[test]
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
for (role, client, threaded) in [
(Client, true, true),
(IntegratedServer, false, false),
(DedicatedServer, false, false),
] {
assert_eq!(role.is_client(), client);
assert_eq!(role.starts_integrated_server(true), threaded);
assert!(!role.starts_integrated_server(false));
}
}
}
+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)> = {
+1 -1
View File
@@ -74,7 +74,7 @@ impl Game<'_> {
})
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
FogType::Water => CameraFluid::Water,
FogType::Lava => CameraFluid::Lava,
+6 -9
View File
@@ -12,7 +12,9 @@
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
};
use horizon_client::{
Result,
@@ -26,7 +28,6 @@ use horizon_protocol::connection::Pipe;
use horizon_protocol::{crypto::offline_uuid, text::translatable};
use std::{
net::TcpListener,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
@@ -170,11 +171,6 @@ struct Launch {
player: String,
}
/// Whether this world opens on a server thread: asked for.
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
options.server_thread && options.dedicated.is_none()
}
impl Game<'_> {
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
/// in the frame or on its server thread.
@@ -215,9 +211,10 @@ impl Game<'_> {
};
let mut options = self.options.clone();
// A server without a console: no local player, no window, no control socket.
// Compatibility for gameplay checks outside startup/world loading.
options.dedicated = Some(Vec::new());
options.server_thread = false;
options.integrated = true;
options.execution_role = ExecutionRole::IntegratedServer;
options.control = None;
options.benchmark = None;
options.frames = None;
@@ -287,13 +284,13 @@ impl Game<'_> {
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
self.open_world(
self.load_world(WorldRequest::new(
&launch.folder,
launch.name.as_deref(),
launch.seed,
launch.creative,
launch.create.as_ref(),
)?;
)?)?;
let saved = Box::new(self.level.clone());
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
+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);
}
+189 -54
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) {
@@ -1939,9 +2039,10 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let sounds = self.as_guest(host, conn, |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,23 +2141,24 @@ 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);
let sounds = events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>();
(sounds, marked.then_some(player.velocity))
let velocity = marked.then_some(player.velocity);
game.record_combat(&events);
(events, velocity)
});
let Some((sounds, marked)) = sounds else {
let Some((events, marked)) = events else {
return;
};
for sound in sounds {
self.player_hurt_sound(sound, at);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
_ => {}
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
@@ -2452,6 +2561,32 @@ impl Game<'_> {
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
.to_vec();
let facts = self.res.facts();
// BlockItem.place: `playSound(player, ...)`, for the others only. The
// placer's client plays it as its block appears.
let placed: Vec<([f64; 3], String)> = changed
.iter()
.map(|&(x, y, z)| {
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
(at, name.trim_start_matches("minecraft:").to_owned())
})
.collect();
if let Some(capture) = self.audio.capture.as_mut() {
let mut i = heard.min(capture.len());
while i < capture.len() {
let (name, at, ..) = &capture[i];
let place = placed
.iter()
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
if place {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
if changed.is_empty()
|| !changed.iter().all(|&(x, y, z)| {
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
+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.
+11 -16
View File
@@ -321,20 +321,7 @@ impl Game<'_> {
}
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
use horizon_client::projectiles::ProjectileKind as K;
let kind = match &p.kind {
K::Arrow { .. } => "minecraft:arrow",
K::Snowball => "minecraft:snowball",
K::Egg => "minecraft:egg",
K::EnderPearl => "minecraft:ender_pearl",
K::SmallFireball => "minecraft:small_fireball",
K::LargeFireball { .. } => "minecraft:fireball",
K::LlamaSpit => "minecraft:llama_spit",
K::WindCharge => "minecraft:wind_charge",
K::BreezeWindCharge => "minecraft:breeze_wind_charge",
K::ShulkerBullet { .. } => "minecraft:shulker_bullet",
K::WitherSkull { .. } => "minecraft:wither_skull",
K::Potion { .. } => "minecraft:splash_potion",
};
let kind = p.kind.id();
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
@@ -417,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
}
@@ -48,10 +48,9 @@ impl Game<'_> {
if let Some(id) = state.mob
&& state.session.villager
{
// startTrading with the guest's reputation (their effects are
// not modelled on the host: no Hero of the Village).
// startTrading with the guest's reputation. The session already
// captured their Hero effect while they acted as the player.
state.session.reputation = self.villager_reputation_of(id, uuid);
state.session.hero = None;
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
+2 -2
View File
@@ -221,7 +221,7 @@ impl Game<'_> {
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.dedicated.is_none() {
if self.options.execution_role.is_client() {
return Ok(());
}
if target != self.dim {
@@ -261,7 +261,7 @@ impl Game<'_> {
/// nobody is in it; returns the level to put back afterwards. Only a
/// server holds other levels.
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
if self.options.dedicated.is_none() || dim == self.dim {
if self.options.execution_role.is_client() || dim == self.dim {
return None;
}
let back = self.dim;
+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());
+14 -6
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).
@@ -156,6 +155,8 @@ impl Game<'_> {
kind.as_str(),
"minecraft:experience_orb"
| "minecraft:arrow"
| "minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:falling_block"
| "minecraft:end_crystal"
| "minecraft:ender_dragon"
@@ -195,6 +196,11 @@ impl Game<'_> {
self.creatures.projectiles.push(p);
}
}
"minecraft:fireball" | "minecraft:small_fireball" => {
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.creatures.projectiles.push(p);
}
}
"minecraft:falling_block" => {
if let Some((state, position, velocity, age, fall_distance)) =
entity_nbt::falling_block_from_nbt(&tag)
@@ -642,13 +648,15 @@ impl Game<'_> {
} else {
None
};
let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position);
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
let in_block = p
.arrow
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) {
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()));
if let Some(tag) = tag {
out.entry(chunk_of(p.position)).or_default().push(tag);
}
}
@@ -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(
+42 -32
View File
@@ -6,7 +6,6 @@ use super::{Dim, Game};
use horizon_client::mobs::villager as mobs_villager;
use horizon_client::{
effects::{EffectInstance, OmenTick},
hud_overlays,
mobs::{
Mob, Species, raider,
spawn_rules::{self, SpawnLevel},
@@ -15,9 +14,9 @@ use horizon_client::{
},
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_world::nbt::Tag;
use std::collections::{HashMap, HashSet};
/// Patrol and raid state owned by the game.
@@ -1063,37 +1062,18 @@ impl Game<'_> {
}
}
/// The raid boss bars: red and notched in tens, with the raid's title.
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for raid in self.raiding.raids.map.values() {
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
continue;
}
let title = self.translate("event.minecraft.raid", "Raid");
let name = match raid.bar.name {
BarName::Raid => title,
BarName::Remaining(n) => format!(
"{title} - {}",
self.translate(
"event.minecraft.raid.raiders_remaining",
"Raiders Remaining: %s"
)
.replace("%s", &n.to_string())
),
BarName::Victory => {
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
}
BarName::Defeat => {
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
}
};
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress: raid.bar.progress,
color: 2,
overlay: 2,
});
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
/// notched in tens. The name is the translate component the client draws.
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
if !alive || self.dim != Dim::Overworld {
return None;
}
let block = block_of(Vec3::new(at[0], at[1], at[2]));
let (id, raid) = self.raiding.raids.nearby(block)?;
if !raid.bar.visible || raid.is_stopped() {
return None;
}
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
}
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
@@ -1239,6 +1219,36 @@ impl Game<'_> {
}
}
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
/// the victory and defeat lines.
fn raid_name_tag(name: BarName) -> Tag {
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
match name {
BarName::Raid => key("event.minecraft.raid"),
BarName::Victory => key("event.minecraft.raid.victory.full"),
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
BarName::Remaining(n) => Tag::compound([
("translate", Tag::String("event.minecraft.raid".into())),
(
"extra",
Tag::List(vec![
Tag::String(" - ".into()),
Tag::compound([
(
"translate",
Tag::String("event.minecraft.raid.raiders_remaining".into()),
),
(
"with",
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
),
]),
]),
),
]),
}
}
#[cfg(test)]
mod tests {
use super::*;
+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,
));
}
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more