Compare commits

...
424 Commits
Author SHA1 Message Date
Ludovic Marie 8674aa784d Merge remote-tracking branch 'origin/main' into mm1-1426 2026-10-23 20:38:10 +02:00
Ludovic Marie 33e35d37c0 Merge pull request #1267 from ludovic111/feat/java-packs-photon-chocapic
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-23 20:32:04 +02:00
Ludovic Marie f08a1016fd Merge remote-tracking branch 'origin/main' into mm1-1426 2026-10-23 20:21:26 +02:00
Ludovic Marie e247790c83 Merge pull request #1472 from ludovic111/fix/main-build-1432
🤖 Agent: merged after verify passed; main builds again. Local proof on the Mac mini: cargo check -p horizon-client --bins --tests fails on 8a4ebcc55 with the two E0599 errors and passes here.
2026-10-23 20:15:12 +02:00
Ludovic Marie ceb12718cb Merge pull request #1448 from ludovic111/chore/python-helpers-lab
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-23 20:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 f74d2fd5f4 Restore Guest::send_all and Behaviours::bone_meal_spreads: main builds again
#1432 deleted both as unused; #1424 (level events) and #1425 (scoreboard)
merged after it and call them, so horizon-client's binary failed with
E0599 at level_particles.rs:54 and scoreboard.rs:338. Both functions come
back unchanged from 4d7f30f14^1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 20:05:12 +02:00
Ludovic Marie 8a4ebcc553 Merge pull request #1425 from ludovic111/fix/st-hud
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:38:45 +02:00
Ludovic Marie 9ff9055b4f Merge pull request #1424 from ludovic111/fix/st-level-events
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:37:19 +02:00
Ludovic Marie 0495d3cbdd Merge pull request #1423 from ludovic111/fix/st-thrown
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:35:52 +02:00
Ludovic Marie 4d7f30f145 Merge pull request #1432 from ludovic111/chore/delete-unused-pub-items
🤖 Agent: merged after verify and CI parity passed (review-0-survey quick win: dead pub items; the generated version.rs is kept as the generator writes it).
2026-10-23 19:17:10 +02:00
Ludovic Marie b65e082fd7 Merge pull request #1450 from ludovic111/chore/client-zip-crate
🤖 Agent: merged after verify and CI parity passed (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-23 18:02:49 +02:00
Ludovic Marie 036e6bd8d6 Merge pull request #1444 from ludovic111/fix/parity-shard-world-spawn
🤖 Agent: merged after verify and CI parity passed. world_spawn runs as 4 shards, so the job's tail drops from 1,436 s to about 465 s.
2026-10-23 17:40:37 +02:00
Ludovic Marie 80d6252734 Merge pull request #1443 from ludovic111/fix/parity-prebuild-selection
🤖 Agent: merged after verify passed. The parity probe prebuild repeats each harness's own -p and features selection, so harness builds after the prebuild compile nothing (0 of 10, was 10 of 10). The full harness set ran on it locally in #1453's before/after diff.
2026-10-23 17:39:09 +02:00
Ludovic Marie f7144ccd19 Merge pull request #1435 from ludovic111/fix/ci-verify-no-examples
🤖 Agent: merged after verify passed. CI verify no longer builds the ~275 examples: the job goes from 7.8 to 5.6 min (review-1-ci).
2026-10-23 17:37:42 +02:00
Ludovic Marie a74d6e49e6 Merge pull request #1458 from ludovic111/fix/high-res-pack-gui
🤖 Agent: merged after verify passed, with local parity proof (103/103 on this head). GUI sprites are measured in GUI units whatever the pack's resolution, as in vanilla: inventories, tooltips and toasts look right with Faithful 32x and 64x.
2026-10-23 17:31:27 +02:00
Ludovic Marie ea426bd53d Merge pull request #1457 from ludovic111/fix/resource-pack-crash
🤖 Agent: merged after verify passed, with local parity proof: parity --changed passed 103/103 on #1458's head, which contains this PR. Faithful 64x reproduced a startup panic on main (a 32768 px shared atlas over the 16384 GPU limit); with the fix it reaches the world. A pack that can't fit now falls back to the default resources, as vanilla does.
2026-10-23 17:29:56 +02:00
Ludovic Marie ab2bc22515 Merge origin/main into feat/java-packs-photon-chocapic
# Conflicts:
#	crates/horizon-shaderpack/src/translate.rs
#	docs/shader-packs.md
2026-10-23 17:20:17 +02:00
Ludovic Marie ce3284a516 Merge pull request #1429 from ludovic111/feat/launcher-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 17:11:04 +02:00
Ludovic Marie 9137279e10 Merge pull request #1418 from ludovic111/fix/st2-saved-state
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame (bugs-st-2).
2026-10-23 17:09:33 +02:00
Ludovic Marie 55034fcce1 Merge pull request #1446 from ludovic111/fix/macos-guest-cdylib-link
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:37:13 +02:00
Ludovic Marie 9a8003df0b Merge pull request #1445 from ludovic111/chore/delete-dead-tools
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:35:43 +02:00
Ludovic Marie d759a89cf0 Merge pull request #1451 from ludovic111/docs/agent-map
🤖 Agent: merged after verify passed; docs only (AGENTS.md, CLAUDE.md symlink, docs/architecture.md). Every path and harness name was checked against the repo.
2026-10-23 16:31:47 +02:00
Ludovic MarieandClaude Opus 5.5 1960dc6eb2 AGENTS.md: agents never add release-now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:31 +02:00
Ludovic MarieandClaude Opus 5.5 7c42cc63ce parity: leave out of the prebuild probes that no longer exist
A record naming an example since renamed or deleted made the whole Cargo
run fail ("no example target named ..."), so no probe was prebuilt and
every harness built its own: the probe binaries rename ~250 of them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:27 +02:00
Ludovic MarieandClaude Opus 5.5 79ee670d79 High-resolution resource packs draw the GUI at its size
With Faithful 32x the inventory showed the top-left quarter of its
background stretched over the screen, tooltips lost their background and
toasts doubled in size (#1442): the GUI code drew every atlas sprite in
texels, where vanilla draws in GUI units whatever the pack's resolution.

- Atlas sprites carry their GUI size: the size of the default resources'
  texture of that name (what vanilla's `blit(..., 256, 256)` calls and
  sprite sizes assume), their texels only for what copies pixels (font
  sheets, fire frames). Textures only a pack has keep their own size.
  The texture size filter reads the GUI size too, so Faithful 64x's
  1024x1024 container backgrounds are no longer dropped.
- Nine-slice sprites follow `GuiGraphicsExtractor.blitNineSlicedSprite`:
  their `gui.scaling` width, height and borders (Faithful's tooltip
  background is 50x50 with a 10-pixel border), `stretch_inner`, the
  single-quad and three-part cases, and borders capped at half the
  drawn size by integer division.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic MarieandClaude Opus 5.5 22ad65611e High-resolution resource packs no longer crash the game at launch
The shared GUI atlas was always 4096 texels wide, so Faithful 64x needed
4096x32768: past the 16384 texels a side GPUs allow, and wgpu panicked
in create_texture at every launch until the pack was removed (#1442).

- The atlas widens (4096, 8192, 16384) until it fits within 16384 a
  side, so Faithful 64x loads. Its height rounds up to a multiple of
  1024 instead of a power of two: the default resources' atlas drops
  from 4096x8192 to 4096x5120, Faithful 32x's from 4096x16384 to
  4096x12288.
- Textures that still don't fit are an error, not a GPU panic. At
  startup it rolls the packs back as Minecraft.rollbackResourcePacks
  does: the game starts with its own resources, and the selection is
  cleared and saved (clearResourcePacksOnError), so the next launch
  doesn't fail again. The existing "Resource reload failed" screen
  shows the reason.
- The renderer refuses an atlas larger than the device allows with an
  error instead of a wgpu validation panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic Marie 172ae241f1 Merge pull request #1430 from ludovic111/feat/release-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 16:24:35 +02:00
Ludovic MarieandClaude Opus 5.5 2eb699228a AGENTS.md and docs/architecture.md: a map of the code for agents
AGENTS.md (CLAUDE.md links to it): the crates and what each owns, how to
build and test fast, where each subsystem of horizon-client lives, how Game
is organised, the two single-player paths and how to test both, the vanilla
reference, and the rules that bite. docs/architecture.md: feature -> files ->
checks for the 40 areas changed most in the last 30 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:18:44 +02:00
Ludovic Marie 1487a1bff7 Merge pull request #1434 from ludovic111/perf/metal-cull-flat
🤖 Agent: merged after verify and CI parity passed. Metal GPU occlusion culling: on the M4 Pro at RD 32, FPS is +8% to +48% (interleaved A/B against main); the hidden sections match Vulkan's; parity --changed passed 104/104.
2026-10-23 16:03:14 +02:00
Ludovic MarieandClaude Opus 5.5 60bad91a50 Client zip.rs reads archives with the zip crate it already links
The 120-line hand-written central-directory and local-header parser goes:
the zip crate (already the client's dependency for world backups) indexes
the archive, zip64 and odd offsets included. Entries are still read raw and
inflated here, so as with Java's ZipFile (vanilla's FilePackResources)
their CRC isn't checked. New tests: folder entries, a nested pack root, a
Windows-style name, one broken entry among good ones, and files that aren't
zips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:31:51 +02:00
Ludovic MarieandClaude Opus 5.5 3960c09159 tools: one copy of the helpers harnesses share, in lab.py
free_port (7 copies), offline_uuid (4), varint and parse_varint (3 and 2),
dbits (6), fbits (4) and settled (2) move into lab.py; every caller uses
lab's. The other server harnesses keep importing free_port from
test_dedicated_server_parity, which now takes lab's. Imports the moved
code left unused go too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:20:06 +02:00
Ludovic MarieandClaude Opus 5.5 5ca16b97fc Keep version.rs as tools/gen_version_consts.py writes it
The file is generated from the pinned version lock, and test_gen_version_consts
checks JAVA_VERSION against it; deleting constants by hand breaks the next
regeneration and that test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:07:38 +02:00
Ludovic MarieandClaude Opus 5.5 eeb4cdb680 Mod SDK: export_mod! exports only in WebAssembly, so the guests link on macOS
A host build of a mod (cargo build or test on the workspace) made a cdylib
whose component exports macOS's ld couldn't take: its exported-symbols list
cuts `horizon:mods/[email protected]#init` at the `#`, so coords-hud and
horizon-mods-test-guest failed to link. The exports now exist only for
wasm32; on the host the macro still checks the mod and keeps it used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:06:50 +02:00
Ludovic Marie 2439475716 Merge pull request #1414 from kerneldroid/feat/shader-api2-pipeline
🤖 Agent: merged after the contribution gate. The claims were checked against Iris and OptiFine, and the weak parts were rewritten on the branch: the stage kinds, the Java shadow feed and the fake uniforms were removed; settings now apply on Done; packs are imported by drag and drop; memory is only allocated while a pack reads it; the MakeUp target is fixed. Tested on Vulkan and Metal with 6 packs, profiles, imports and broken packs; local parity passed 96/96. Frame times before and after are in the PR comment. Thanks @kerneldroid.
2026-10-23 15:05:34 +02:00
Ludovic Marie 1959c397a0 Merge pull request #1433 from ludovic111/chore/unify-naga-features
🤖 Agent: merged after verify and CI parity passed (review-0-survey quick win; see horizon-parity reports/review-plan.md).
2026-10-23 14:44:50 +02:00
Ludovic MarieandClaude Opus 5.5 7cc47e2b55 Delete ten tools/ scripts nothing references
command_parse_summary, structure_start_oracle, fetch_client_assets,
compare_structure_starts, profile_behaviour_cache, command_tree_diff,
compare_loot_fill, compare_structure_blocks, test_lan_lifecycle and
PROFILE_SHARED_JSON.md: no workflow, doc, harness, test or other tool
names them (review-plan.md, Dead code). History keeps them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:41:40 +02:00
Ludovic MarieandClaude Opus 5.5 c9c53629d4 parity: run world_spawn in four shards
world_spawn starts a vanilla and a Horizon server for each of 60 seeds,
one at a time: 1,144-1,548 s on the CI runner, the parity job's longest
harness and its floor whenever a PR's changes reach it. With
PARITY_SHARDS = 4 each shard creates every fourth seed's worlds, in its
own build directory.

The shard helper datafix had moves to tools/parity_shard.py, which both
use. It stays out of lab.py, which every harness imports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:36:58 +02:00
Ludovic Marie 6fe88978a7 Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 d9591047fa Shader pack docs: MakeUp, Chocapic13' and Photon on the Java path, and where the world goes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 fe3a3af568 Shader Packs page: the pack's settings button sits beside the folder button
As in Iris's ShaderPackScreen: at the end of the list it scrolled out
of sight with a few packs. It's greyed out while the picked pack has no
options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 ac54227465 parity: prebuild probes with each harness's own package selection
The probe prebuild ran `cargo build --workspace --examples --bins` (or
named targets with --workspace). The harnesses build with `-p <crate>`, and
Cargo unifies features over the selected packages: under --workspace,
flate2, libc, memchr, syn, thiserror, postcard and digest get other
features for horizon-world, and horizon-mods (pack) and horizon-telemetry
(upload) for horizon-client. So every probe the prebuild made was a
variant no harness used, and each harness rebuilt its crate and its probe
again while the others waited on Cargo's lock.

Record each probe with its command's package and feature flags and
whether that command builds examples (which turns dev-dependencies on),
and prebuild one Cargo run per such selection. --bins and --examples are
recorded too, so the fast-profile horizon-client build that
save_roundtrip and world_reopen run is prebuilt as well. A harness that
never ran here takes its probes from the shared dependency lists instead
of a build of all ~275 examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:11:22 +02:00
Ludovic MarieandClaude Opus 5.5 4a61134edd CI verify: test without building the examples
cargo test --workspace built and linked all ~275 examples, 59% of the
test build (review-plan section 1), though none of their tests run.
Test the libraries, binaries and integration tests, then the doc tests,
as tools/gate.py and tools/pr_ready.py already do. Clippy --all-targets
still type-checks every example, and the parity job builds and runs them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:06:25 +02:00
Ludovic MarieandClaude Opus 5.5 39ae543796 Shader packs: test importing zips and folders, and refusing what isn't a pack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 963d5cedd3 Shader packs: keep the opaque depth and G-buffer only for packs that read them
The world target made the opaque depth, normal and material textures
for every scaled or anti-aliased frame, packs or not: 12 bytes a pixel
nobody read. They now follow what the pack reads, and Post::generation
tells the stages to bind again when its targets change (a recreated
target of the same size, or a shadow map made after the stages bound).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 fe7bb3a34b Java packs: the world goes where the pack's terrain program draws
MakeUp's gbuffers write colortex1, and its composites read the world
from there: with the world in colortex0, it showed nothing but fog.
Iris's fallbacks for terrain pick the program (gbuffers_terrain, then
textured_lit, textured, basic); its first draw buffer takes the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 eafe97da74 Shader packs: keep what Iris does, drop what doesn't work, apply settings on Done
Review fixes for #1414 (contribution gate):

- Java packs no longer get Horizon's shadow map: it was mirrored in y and
  reversed in depth against the GL conventions the packs sample with, had
  no distortion and only the camera's sections, so BSL drew no shadows
  while the pass cost 0.65 ms (MakeUp, M4 Pro). shadowtex* stays a far
  depth, as on main; the packs' own shadow programs are #1326.
- Invented uniforms removed (centerDepthSmooth fixed at 0.5,
  ambientOcclusionLevel and shadowMapResolution as uniforms, constant
  temp/rainfall/biome/held items); an unknown uniform stays zero as
  before. wetness keeps its Iris smoothing. Unused directives removed.
- texture.<stage>.<sampler> is scoped to Iris's stages (final reads the
  composite ones) instead of overriding the sampler in every pass;
  texture.noise replaces noisetex. One noise generator and one RGBA
  upload for both pack kinds.
- The G-buffer pass, the opaque-depth copy and the shadow pass run only
  for a pack that reads them (naga's per-entry-point binding use, not a
  text search): every pack paid all three before.
- Api 2's prelude additions go in their own file after api 1's, so api 1
  packs keep the names they declare (aces_tonemap, material_at...).
- Stage kinds shadow, gbuffer, deferred and final ran as composite: only
  composite is accepted; api 2 adds formats (validated), profiles,
  option descriptions and a noise texture.
- Shader Pack Settings applies once when the page closes, as Iris's Done
  (each click or slider step rebuilt the pack: 2.7 s per click, 12.7 s
  for one drag with BSL). Labels follow Iris (.lang, else the name).
  A pack whose options can't be read isn't read again every frame.
- Saved option values an option doesn't list are ignored, as OptiFine's
  ShaderOption.setValue (a stale file broke BSL's compile).
- Import: the blocking file dialog (osascript/zenity/kdialog/PowerShell
  on the event loop) is gone; a pack dropped on the Shader Packs page is
  copied and picked, as Iris's onFilesDrop. Drops elsewhere do nothing,
  an existing pack isn't overwritten, links aren't followed.
- Docs and the example say what the code does (SSAO, not GTAO).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic Marie d95932e62c Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 660407fa58 Metal: cull only frames with 4,096 section draws or more
On Apple's GPUs the passes the test adds cost a fixed share of the frame
(it is stored and reloaded at each), which the hidden sections repay only
at high render distances: at 12 culling cost an M4 Pro 0.05 ms of GPU time
a frame, at 32 it saved 0.25 to 1.6 ms. Other backends keep culling every
frame; HORIZON_GPU_CULL=all culls every frame on Metal too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:22:10 +02:00
Ludovic Marie 6be7daebc8 Merge pull request #1417 from ludovic111/fix/st2-rules
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame with all four merged on main (bugs-st-2).
2026-10-23 13:20:42 +02:00
Ludovic Marie 2639e9e1d7 Merge pull request #1422 from ludovic111/fix/st-leads
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 13:20:40 +02:00
Ludovic MarieandClaude Opus 5.5 1d81c67b7b Build naga once for the game, its tests and its examples
horizon-client's dev-dependency on naga added the hlsl-out feature for the
gfx.rs test that translates every shader for DirectX 12. With resolver 2 a
dev-dependency's features apply only to tests and examples, so Cargo kept
two builds of naga, wgpu-hal, wgpu-core, wgpu, horizon-shaderpack and the
horizon-client lib, and rebuilt them on the first example or test after a
game build.

Measured on the Mac mini (cargo build of the game, then one example):
the example took 37.3 s and recompiled that whole stack before, 1.2 s
and only its own crate after; the target dir is 1 GB smaller (4.9 to
3.9 GB). The game binary is the same size: nothing calls the HLSL writer,
so the linker drops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:13:58 +02:00
Ludovic Marie 9c09d2897d Merge pull request #1428 from ludovic111/fix/alpha-regress-1
🤖 Agent: merged after verify and CI parity passed. In-frame single player is the default again until the server thread reaches parity (FEATURES 4zg); release builds were compared with v0.2.0 and v0.2.11 on the Mac mini. The server thread stays available with --server-thread or HORIZON_SERVER_THREAD=1.
2026-10-23 12:11:36 +02:00
Ludovic MarieandClaude Opus 5.5 173a55e608 Delete pub items nothing uses, and the stubs that hid unused code
93 pub items (78 functions and methods, 14 constants, 1 struct) in horizon-client,
horizon-world, horizon-core, horizon-friends and horizon-server-files had
no caller anywhere in the repository: not in the crate, the other crates,
the examples, the tests or the binaries. rustc can't flag them because
horizon-client and the others are libraries, where a pub item counts as
used. The imports and the private helpers only they used go with them
(title.rs date_string, goals.rs flag_index).

Also drops the #[allow(dead_code)]/#[allow(unused)] items that only hid
dead code: chat_debug_fill, merchant_title and Goals::locked_by had no
caller, and four stubs (_keep, _unused, _rule_kind, const _) existed only
to keep an import used. LightTables::from_parts is used by its tests, so
it is #[cfg(test)] instead of allowed.

Protocol id tables and entity data serializer ids stay: they mirror
vanilla's registries in full. The launcher's three unused items are left
for a launcher PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:08:44 +02:00
Ludovic MarieandClaude Opus 5.5 14be6ca394 Metal: occlusion culling with flat draws too
Flat draws (one index list per frame, the default on Apple GPUs at high
render distances) turned occlusion culling off. Now phase 2's draws are
flattened after the test, by a compute pass that copies only the sections
the test saw, and each page run is drawn indirectly with the count it
reached.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:39 +02:00
Ludovic MarieandClaude Opus 5.5 9fcf91e4bc Metal: occlusion culling of solid sections, the depth test in the shader
GPU occlusion culling ran on Vulkan only: its test needs early fragment
tests, which naga's Metal backend does not emit. On Metal the boxes are
tested in a pass of their own: phase 1's depth is bound read-only as the
attachment and as a texture, and the fragment shader compares its own
depth with it (reversed Z, greater or equal), as the fixed-function test
would. HORIZON_GPU_CULL=shader takes this path on Vulkan too, for checks.

Frames drawn flat (the default on Apple GPUs at high render distances)
still skip culling here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 630f521072 Launcher: one update channel, and the menu entry is never taken over
Every release is now published as the release (one channel, Ludovic's
decision of 2026-10-23), so the launcher drops its Stable/Alpha choice:
the Updates row in Settings, `horizon-launcher update-channel` and its MCP
tool, the channel on the Versions page, in `versions` and in the footer.
It updates to the newest game release that is not a pre-release (GitHub's
latest) and looks every 30 minutes. The pre-releases of the old Alpha
channel stay listed and installable by hand, never as an update. Old
`update_channel` / `beta_builds` keys in settings.json are ignored.

On Linux a release build that starts no longer rewrites the
applications-menu entry to point at itself. It writes the entry only when
there is none, or when the program it starts (TryExec, else Exec) no longer
exists; turning the Settings switch on or `desktop-entry` still points it
at this launcher, and HORIZON_LAUNCHER_DESKTOP_EXEC is still honored. A
test build started with the real HOME took over a working entry and then
left it broken when its target/ was deleted (2026-10-23).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic MarieandClaude Opus 5.5 0e65e85878 Releases: one channel, every release is the latest once its checks pass
Ludovic's decision (2026-10-23): no more Alpha and Stable. release.yml
publishes every game release as the release and GitHub's latest instead of
a pre-release that promote.yml made stable once a day. The checks that
gated a promotion run in the release itself: `decide` holds the train while
an issue labelled release-blocker is open (forced runs too), and the publish
job runs `release_train.py gate` right before publishing: the Mac mini's
smoke-test.json must be there and show a world was drawn, and no blocker may
be open. Otherwise nothing is published.

Notes cover everything since the last release that is not a pre-release, so
the first single-channel release lists all of it since v0.2.0. promote.yml,
tools/release_channels.py and its tests are deleted. The cadence is
unchanged (5 minutes of settling, 20 at most, release-now at once).

Docs and site: one channel in release-flow, versioning, security, the FAQ,
install, the home page and the Releases page (Game / Launcher filter); no
player-facing Alpha wording left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic Marie 62a6b3a620 Merge pull request #1421 from ludovic111/fix/st-entity-data
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 11:12:06 +02:00
Ludovic MarieandClaude Opus 5.5 6049012b52 Single player runs in the frame by default again; the server thread is opt-in
Players on the Alpha channel report single player as broken since v0.2.1
(#1265 made the integrated server thread the default again). On v0.2.11's
server thread, placed signs open no editor (#1380), held books don't open
(#1366), a totem doesn't save its holder (#1292) and death keeps the
inventory with keep_inventory off (#1284); the in-frame world and Stable
v0.2.0 do all four right. About 45 more [server-thread] issues are open.

`--server-thread` or HORIZON_SERVER_THREAD=1 turns it on, as before #1114;
`--no-server-thread` and HORIZON_SERVER_THREAD=0 still work. LAN guests and
dedicated servers are untouched. Temporary until the server thread reaches
parity (FEATURES 4zg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:54:42 +02:00
Ludovic MarieandClaude Opus 5.5 4aa1f8e14a World border on a server's clients: its state, its walls, its damage
The server told joining players of a default border and never of a
change, so on single player's server thread and for LAN guests the wall
neither showed nor stopped anyone. Players now get the real border on
entering a level and each change as BorderChangeListener sends it.

The player's movement collides with the border's walls
(CollisionGetter.borderCollision) instead of being clamped after moving,
which a sprint-jump got past. A guest (and single player's own player on
its server thread) outside the border takes its damage on the server, as
the host's player did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 b7f4cba355 A server's scoreboard reaches its players: sidebar, teams, scores
The server never sent its scoreboard, so a sidebar shown on single
player's server thread or to a LAN guest stayed empty. Each tick a placed
player is now sent what changed since it was last sent the board, as
ServerScoreboard's callbacks do: every team, and the objectives in a
display slot with their slots and scores (all of it on joining).

A server's client also finds its team's sidebar by its profile name rather
than single player's "Player".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 13772b4e5b Level events on a server's clients: spawner bursts, bone meal sparks, waxing
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) played only three of the level events the server
sent (a block broken, the dragon egg's and a consumable's teleport): a
spawner's burst, waxing and scraping copper, a composter filling and the rest
showed nothing. Every one now goes through the handler the in-frame game uses
(LevelEventHandler).

Bone meal's level event (1505) played its sound but never its growth
particles, in either mode: it now spawns them as BoneMealItem.
addGrowthParticles does (spread over a grass block's, nylium's, netherrack's
or moss's neighbourhood and water, inside a growing block, below rooted dirt
and mangrove leaves). The server also sends it to the player who used the
bone meal, as vanilla's levelEvent names no player.

Waxing copper with honeycomb and an axe or hoe's transforms (wax off, scrape,
strip, till) make their particles and sound on the clicker's own client, as
vanilla's client runs Item.useOn; the server sends them to the others only.
The transform's preview is shared with the in-frame use.

`horizon-ctl status` counts the particles made by type. lan_e2e's new
`sparks` check: the host and the guest each use bone meal on wheat, wax
copper and take the wax off, and a spawner spawns a zombie; both sides make
the particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 c5fe5d4bcf Thrown potions and eyes of ender fly on a server's clients
A splash or lingering potion, an experience bottle or an eye of ender a
player threw was never an entity a server tracked: on single player's server
thread, for LAN guests and on a dedicated server the item was used up and
nothing flew.

The server now numbers them like its other projectiles and tracks them as
vanilla's entities: splash_potion, lingering_potion and experience_bottle
with ThrowableItemProjectile.DATA_ITEM_STACK (the potion, for its colour),
and eye_of_ender. A client draws them where the server puts them and drops
them when the server removes them.

lan_e2e's new `thrown` check: the host and the guest each throw a splash
potion and an eye of ender, and both sides see them fly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 0d8249e124 Leads: a server's clients draw the rope (ClientboundSetEntityLinkPacket)
A server never told its clients who holds a mob's lead, so on single
player's server thread, a LAN guest's or a dedicated server's client the
lead was used up and the mob tied, but no rope showed.

The tracker now sends ClientboundSetEntityLinkPacket as vanilla does: in
ServerEntity.sendPairingData for a leashed entity, and when the lead is tied
or comes off (Leashable.setLeashedTo / dropLeash, the holder 0). The holder
is the player the mob sees (a guest, else the host) or a mob; a fence knot
isn't an entity on Horizon's server yet. The client sets the mob's holder
from it: itself, another player or a mob.

lan_e2e's new `leads` check ties and unties a cow on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 c35ea9568f Server's mobs: babies, wool, collars, fire, slime sizes and health reach its clients
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) only applied a rabbit's baby flag and a creeper's
swell from SetEntityData. It now reads the synched data vanilla's entities
read in onSyncedDataUpdated: Entity's on-fire flag, LivingEntity's health
(the wither's boss bar follows it), the baby flag of every ageable mob,
Sheep.DATA_WOOL_ID, TamableAnimal's sitting and tame flags, the wolf's and
the cat's collar colour, and the cube mobs' size. A remote wolf or cat starts
with vanilla's red collar.

The server sends the wolf's and cat's collar colour. Pairing still carries
only the values that differ from the defaults; a mob's state now lists the
defaults of the entries that can go back to theirs (health, the baby flag,
the wool, the collar), and the tracker sends that default when one does:
a calf growing up or a sheep's white wool growing back reach the clients.

Sheep saves wrote the colour and shearing they were loaded with (0 unless
the save had them) and loads ignored both: they now save and load the wool
as it is (Sheep.add/readAdditionalSaveData).

lan_e2e's new `looks` check summons the mobs and changes them on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 f88fba8d05 A server's attribute updates reach its player; new boss bars start visible
The client of a server (single player on its server thread, LAN guests)
ignored ClientboundUpdateAttributesPacket for its own player, so a new
maximum health never reached it: its hearts stayed at ten and the health it
was sent was clamped to 20. It now applies each synced instance's base and
modifiers (handleUpdateAttributes) and re-applies the received health.

BossBar::new started hidden; ServerBossEvent starts visible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic Marie e325fa6587 Merge pull request #1397 from ludovic111/fix/metal-gpu-timer
🤖 Agent: merged after verify and CI parity passed; the change only touches the GPU timer (gfx/timing.rs), proven on the M4 at RD 32.
2026-10-23 09:57:36 +02:00
Ludovic Marie 2423e9da00 Merge remote-tracking branch 'origin/main' into fix/st2-saved-state
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
2026-10-23 09:44:30 +02:00
Ludovic Marie cb81f645da Merge remote-tracking branch 'origin/main' into fix/st2-rules
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-23 09:44:12 +02:00
Ludovic MarieandClaude Opus 5.5 7e5e469783 InLove only for vanilla's Animal subclasses (not allays)
save_roundtrip found an allay saved with InLove 0: Horizon's `animal`
flag is broader than vanilla's Animal class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 09:19:48 +02:00
Ludovic Marie 4680a73f51 Merge pull request #1413 from ludovic111/feat/bench-1080p
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:06:57 +02:00
Ludovic Marie e0cb831dbf Merge pull request #1190 from ludovic111/fix/texture-mcmeta-blur-clamp
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:05:27 +02:00
Ludovic Marie 6b35c35ebf Merge pull request #1416 from ludovic111/fix/bed-respawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 09:00:44 +02:00
Ludovic Marie 48863fcf84 Merge pull request #1412 from ludovic111/fix/mob-knockback-view
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:59:16 +02:00
Ludovic Marie 982f7f12be Merge pull request #1411 from ludovic111/fix/items-out-of-blocks
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:58:50 +02:00
Ludovic Marie 12f2ed9ff7 Merge pull request #1410 from ludovic111/fix/setworldspawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:57:20 +02:00
Ludovic Marie 10db017c7c Merge pull request #1409 from ludovic111/fix/server-player-shield-mining
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:54:51 +02:00
Ludovic Marie e6acebc23d Merge pull request #1408 from ludovic111/fix/contact-damage
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:53:24 +02:00
Ludovic Marie 69df5671e7 Merge pull request #1407 from ludovic111/fix/server-air-absorption
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:48:53 +02:00
Ludovic MarieandClaude Opus 5.5 06b4c7faf5 Rules: crits, mending, phantoms, per-level health rules, /item with air, frozen potions
- Critical hits also need the attacker out of water, not blind and not
  riding (Player.canCriticalAttack) (#1393).
- Mending repairs the off hand too: candidates in EquipmentSlot order,
  main hand, off hand, then the armour (#1349).
- Phantoms spawn only in the Overworld with spawn_mobs, spawn_monsters and
  spawn_phantoms on, near every player (the local one and each guest in
  the level, with their own time since rest), with vanilla's draw order
  (#1388, #1387).
- A level entered by a portal or a dimension change keeps the world's
  natural_health_regeneration (#1339).
- /item replace ... with air empties the slot, and counts it as changed
  as vanilla's SlotAccess.set does (#1332).
- Thrown potions stay where they are on frozen ticks (/tick freeze) and
  land once the game runs again (#1364).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:48:41 +02:00
Ludovic MarieandClaude Opus 5.5 d522d8fe50 Saved state: burning, air and falling survive a reopen; InLove for every animal
- Guests' player data saves Fire, Air and fall_distance and puts them back
  on their body when they come back (single player's players are guests
  of the integrated server), so a burning player still burns after a
  reopen (#1323); the old single player's level.dat keeps fall_distance
  too (#1327).
- Every Animal saves and loads InLove, so a fed animal stays in love
  across a reopen and is not fed again (#1396).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:29 +02:00
Ludovic Marie 55e7084890 Merge branch 'fix/mob-knockback-view' into fix/bed-respawn 2026-10-23 08:36:16 +02:00
Ludovic Marie cffa98d509 Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 08:36:16 +02:00
Ludovic Marie 9df27670f2 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks 2026-10-23 08:36:16 +02:00
Ludovic Marie 83c6d78013 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn 2026-10-23 08:36:16 +02:00
Ludovic Marie 83db7e3c41 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining 2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 39438efd76 lan_e2e contacts: sneak before landing on magma, read health before letting go
The sneaking case read the health after releasing Shift and waiting 10
ticks on the magma, so a hit from standing up counted (and one could land
between the first reading and the key press).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 0e36cb4d7a Beds: respawning checks the bed and stands up beside it, else the world spawn
A bed sets the respawn point as vanilla's ServerPlayer.startSleepInBed does
(once in reach, day or night): a RespawnConfig at the bed's head with the
player's yaw, not forced; straw beds never set it (straw_bed_rule).
Respawning resolves it through findRespawnAndUseSpawnBlock, which now
handles beds: AbstractBedBlock.findStandUpPosition (beside the bed on the
side away from the yaw it was set from, around it, then on it; bunk beds
too) through DismountHelper, facing the bed. A bed that is gone or boxed
in sends the player to the world spawn with "You have no home bed or
charged respawn anchor, or it was obstructed", and the point is forgotten:
in the window's world, on the server thread and for LAN guests. Leaving
the End goes to the bed too (not using it up).

Saves keep a bed as the player's `respawn` (resolved through its block
when loaded), and a `spawnpoint` in the Overworld as forced. A fallback
that is the world spawn follows `setworldspawn` with a bed or anchor set.

tools/test_use_item_on_parity.py: respawn cases at a bed's head set from
some yaw (intact, bunk, only the foot left or gone, crowded), against
vanilla's real findRespawnAndUseSpawnBlock; 4000/4000 and 12000/12000 on
two seeds. lan_e2e `beds`: intact, gone and boxed in, host and guest.

Fixes #1308

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:11:22 +02:00
kerneldroid 4dff61b697 feat(shaders): complete wgpu Iris/Horizon API-2 shader pipeline, shadow & G-buffer passes, GLSL translator fixes, and settings/import UI 2026-10-23 11:07:03 +05:00
Ludovic Marie 13f98233ea Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-23 07:59:50 +02:00
Ludovic MarieandClaude Opus 5.5 5c9f11b954 Benchmark: a 1080p set next to 720p
perf_bench ran every game in a 1280x720 window only. It now runs the
standard set at 1280x720 and 1920x1080 (--sizes picks others), each size
its own reports: <label>-rd<N>.json at 720p as before, <label>-1080p-rd<N>.json
at 1080p.

Horizon takes vanilla's --width and --height for its window's starting size
(1280x720 by default); perf_bench passes them to every game, and to Horizon
only off 720p, so builds before this one still run the 720p set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:58:53 +02:00
Ludovic Marie c8f00dcf1e Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 07:52:34 +02:00
Ludovic Marie 3d36883f52 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:52:33 +02:00
Ludovic Marie e84f86a813 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn
# Conflicts:
#	crates/horizon-client/src/lan/playerdata.rs
#	tools/lan_e2e.py
2026-10-23 07:51:25 +02:00
Ludovic Marie 48c1b04cb0 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:58 +02:00
Ludovic Marie 10f6b183e3 Merge branch 'fix/server-air-absorption' into fix/contact-damage
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:48 +02:00
Ludovic Marie f949f08488 Merge remote-tracking branch 'origin/main' into fix/server-air-absorption
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:45:28 +02:00
Ludovic MarieandClaude Opus 5.5 144741b7c7 Screen overlays: one texture sample, as on main
The vignette, drawn over every frame, went from one texture sample to up to
four texel loads and per-pixel address arithmetic: +0.85 ms of GPU a frame on
an HD 620 (integer modulo, removed in the commit before), still +0.4 ms after
it.

- A blurred sprite gets a one-texel border in the atlas, copied from its
  opposite edges: the hardware's bilinear sample of the atlas with the
  entity group's linear sampler is then GL_LINEAR with repeat, edges
  included. Clamp-to-edge keeps the sample point half a texel inside the
  sprite, which is what the hardware's clamp does for both filters.
- Each sampling has its own pipeline, the shader specialised by override
  constants: a repeat sprite runs main's arithmetic and one sample, nearest
  or linear; only clamp adds a clamp. The sampling moves from the vertices
  to the quad, so the vertex layout is main's again.
- A black vignette changes no pixel (it darkens by texture x colour), and is
  no longer drawn: in daylight its brightness fades to 0 within half a
  minute, so most frames outdoors skip it, which main never could.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:35:26 +02:00
Ludovic Marie 5bb492ca54 Merge pull request #1318 from levzzz5154/fix/tnt-minecart-explosions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and six TNT minecarts now chain (6 → 0; 5 survive on main). Thanks @levzzz5154!
2026-10-23 06:56:31 +02:00
Ludovic Marie 15616aa115 Merge pull request #1317 from levzzz5154/fix/minecart-collisions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and lan_e2e minecarts passes in both single-player modes. Thanks @levzzz5154!
2026-10-23 06:56:07 +02:00
Ludovic Marie 910f59bf0e Merge pull request #1213 from ludovic111/fix/server-respawn-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:27:48 +02:00
Ludovic Marie 247a33d889 Merge pull request #1212 from ludovic111/fix/server-end-gateways
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:26:20 +02:00
Ludovic Marie a1597c041f Merge pull request #1211 from ludovic111/fix/server-command-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:24:51 +02:00
Ludovic Marie 1a4e783a04 Merge pull request #1210 from ludovic111/feat/lan-levels-check
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:23:22 +02:00
Ludovic Marie 91466d160c Merge pull request #1216 from ludovic111/feat/truetype-fonts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:21:55 +02:00
Ludovic Marie ce04baf1ba Merge pull request #1215 from ludovic111/feat/win-screen
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:20:28 +02:00
Ludovic Marie b75370cf26 Merge remote-tracking branch 'origin/main' into m1215
# Conflicts:
#	crates/horizon-client/src/game/startup_menu.rs
#	crates/horizon-client/src/options_ui.rs
2026-10-23 06:11:37 +02:00
Ludovic MarieandClaude Opus 5.5 feeefb3a33 Mobs: a punch shows smooth and slow, as on vanilla's client
The server sends a knocked-back, pushed or jumping mob's move at once
(Entity.needsSync), and the remote client walks every living entity along
the server's positions with vanilla's SteppedInterpolationHandler: each
position takes the type's update interval to reach, sped up while steps
pile up. Before, the client waited for the next due update and then
lerped there in fewer ticks, so a hit pig jumped away fast.

tools/test_stepped_interpolation_parity.py drives vanilla's unmodified
InterpolationData and the Rust port with the same packets and ticks
(random cases and a punched pig's knockback); 135236 positions and
rotations match bit for bit.

Fixes #1374

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:46 +02:00
Ludovic MarieandClaude Opus 5.5 65bd2d0d45 Items push out of blocks; a server's client digs at the server's speed
ItemEntity.tick: an item stuck inside a full block (one set on it) has no
physics on the server and moves toward the closest free side
(Entity.moveTowardsClosestSpace: north, south, west, east, up; 0.1 to 0.3 a
tick), instead of staying in the block (#1252).

A server's client (LAN guests, and the window on the server thread) digs at
the same speed as the server: getDestroyProgress with the submerged mining
speed, haste, fatigue and efficiency, not the plain tool speed, so it no
longer finishes early under water and gets the block put back (#1254).

lan_e2e: `drops` (a diamond under a block set on it comes out, in both
views) and `underwater` (dirt by hand at eye height under water: about
halfway at 35 ticks on client and server, broken by 105).

Fixes #1252, #1254.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 5c4600e403 /setworldspawn moves the world spawn, not the player's respawn point
SetWorldSpawnCommand sets the level's spawn and its angle; a player without
a bed or anchor respawns there (it used to set the player's own spawn
point, so other players and later deaths kept the old one). The angle is
saved in level.dat's spawn (yaw, pitch), and a guest whose saved respawn
point is the world spawn follows it when it moves.

lan_e2e `worldspawn`: /setworldspawn 24 blocks east, then the host and the
guest die and respawn there.

Fixes #1310.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 2898b40670 Server players: an axe-disabled shield stays down 5 s, death forgets the digging
ItemCooldowns ticks once a tick for each player: a server's players count
their shield cooldown down in tick_guests, and the host's world in tick_world
(it was in tick_using, which ran for the host and again for every guest's
swap, so a guest's shield came back early on the server thread).

A guest that dies stops digging (its mining, the delay and the crack go), so
the block it was breaking doesn't break once it respawns.

lan_e2e: `shield` (a vindicator's axe disables each player's shield; the
server's cooldown then falls 40 in 40 ticks) and `deathmining` (die while
breaking obsidian, respawn, the obsidian stays). `lan status` and the agent
state report the shield cooldown.

Fixes #1315, #1316.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 ed28e1dd0d Contact damage: magma, cactus, lit campfires and sweet berry bushes hurt in both modes
Entity.applyEffectsFromBlocks: MagmaBlock.stepOn burns a player standing on
it who is not sneaking (hot_floor, 1), CactusBlock and CampfireBlock.entityInside
hurt what touches them (cactus 1, campfire 1, soul campfire 2, lit only).
A server's players (LAN guests, and the host on the server thread) now get
these and the grown sweet berry bush prick along the moves their client sent
that tick: the server rebuilds the tick's movement history and runs the
damage half of the block effects.

lan_e2e `contacts`: magma, sneaking on magma, a lit campfire, a cactus, grown
and young berry bushes, for the host and the guest.

Fixes #1305, #1311, #1312, #1313.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 7acebbae45 Server players: the HUD's bubbles and golden hearts follow the server, absorption is saved, Nether lava pushes harder
The server sends a player's own air supply and absorption amount
(Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID) when they
change, and the client applies them: on the server thread and as a LAN
guest the breath meter follows the server's Respiration-extended air
(#1304) and the golden hearts show (#1306). The connected client's tick
sets the helmet's Respiration and the Nether's FAST_LAVA as the window's
own tick does (#1309).

AbsorptionAmount is written from the live body and read back on load,
in the window's world and for a server's players (#1307).

lan_e2e: absorption, breath and lava checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 0c133101b6 Screen overlays: wrap texels with a select, not integer modulo
The camera overlays (the vignette every frame) read up to four texels a
pixel, each wrapped with two integer modulos. Intel GPUs emulate integer
division. Reducing the coordinate to one period first (fract, as the shader
did before) leaves every read at most one texel outside the sprite, so a
select wraps it. Same texels and weights; clamp-to-edge is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:21:47 +02:00
Ludovic Marie 9c6b35d4cb Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp 2026-10-23 05:21:47 +02:00
Ludovic Marie 357dceb999 Merge pull request #1197 from ludovic111/feat/locator-bar
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:21:26 +02:00
Ludovic Marie aec75bbd28 Merge pull request #1194 from ludovic111/feat/armour-trims-worn
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:19:59 +02:00
Ludovic Marie 8b6d49c4b4 Merge pull request #1357 from ludovic111/docs/agent-control
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:17:45 +02:00
Ludovic Marie 03c4d2c683 Merge pull request #1353 from ludovic111/feat/agent-connect-game
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:16:19 +02:00
Ludovic Marie 891b122783 Merge pull request #1256 from ludovic111/perf/random-tick-counts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:15:28 +02:00
Ludovic Marie 06b754db4a Merge pull request #1253 from ludovic111/perf/spawn-checks
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:14:01 +02:00
Ludovic Marie 1c18ac6087 Merge pull request #1239 from ludovic111/perf/animated-texture-uploads
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:13:35 +02:00
Ludovic MarieandClaude Opus 5.5 d6f8dda823 Metal: the GPU frame timer measures the frame again (read about 0.01 ms)
On Apple GPUs timestamps go only at pass boundaries, and the frame was
bracketed by two one-thread compute passes. Two things made it read
about 0.01 ms:

- Metal resolves counter samples without waiting for the passes that
  write them. Resolved in the frame's own command buffer, the end
  timestamp was mostly the one an earlier frame left in the shared
  query. Each readback slot now has its own queries, and with markers a
  slot is resolved in a later frame, once on_submitted_work_done says the
  GPU finished the frame it timed.
- A compute pass depends on nothing, so the end marker ran alongside the
  frame's render passes. The end is now a render pass that loads the
  frame's target, shading one pixel with every write masked: it runs
  after every pass that drew into the target. The start stays a compute
  pass (cheaper than a load and store of the target).

M4 Pro, render distance 32, offscreen: GPU p50 0.002 ms before, now
1.95 ms against a 1.95 ms frame (village) and 4.46 ms against 4.51 ms
(mountain), where the frame is GPU-bound. Vulkan and DirectX 12, which
write timestamps between commands, are unchanged but for the per-slot
queries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:13:25 +02:00
Ludovic Marie 0e81f178ec Merge pull request #1258 from ludovic111/perf/frustum-inside-columns
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:12:08 +02:00
Ludovic Marie d4c37adb0b Merge pull request #1199 from ludovic111/feat/far-detail-reduced
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:10:41 +02:00
Ludovic Marie 8120559415 Merge pull request #1198 from ludovic111/perf/frame-encode
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:10:15 +02:00
Ludovic Marie ba85348907 Merge pull request #1191 from ludovic111/perf/mod-content-build
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:08:48 +02:00
Ludovic Marie f73dc03fee Merge pull request #1275 from ludovic111/fix/perf-bench-readiness
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:08:23 +02:00
Ludovic Marie 0623d41954 Merge remote-tracking branch 'origin/main' into m1353
# Conflicts:
#	tools/agent_control_e2e.py
2026-10-23 05:04:53 +02:00
Ludovic Marie f642105e84 Merge remote-tracking branch 'origin/main' into feat/java-packs-photon-chocapic 2026-10-23 04:04:11 +02:00
Ludovic Marie 820dfe1819 Merge pull request #1345 from ludovic111/feat/agent-connect-launcher
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:41:19 +02:00
Ludovic Marie 15b2007f54 Merge pull request #1344 from ludovic111/feat/agent-control-launcher
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:39:51 +02:00
Ludovic Marie d42c246122 Merge pull request #1343 from ludovic111/feat/agent-control-game
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:38:25 +02:00
Ludovic Marie 9e1401cc92 Merge pull request #1342 from ludovic111/feat/no-ai-switch
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:37:59 +02:00
Ludovic Marie f6d82abb66 Merge pull request #1358 from ludovic111/feat/site-desktop-hardware
🤖 Agent: merged after verify passed; the desktop benchmark and the breakdowns for the /benchmarks page (site only).
2026-10-23 03:36:33 +02:00
Ludovic Marie 850354ca28 Merge pull request #1337 from ludovic111/feat/telemetry-hardware
🤖 Agent: merged after verify passed. Ludovic asked on 2026-10-23 for every hardware fact players send (opt-in). The end-to-end run against a local server is in the description: breakdowns need ≥3 installs, with no install ID or exact value. The consent version goes up to 2, so players who opted in are asked again, as the privacy page promises.
2026-10-23 03:35:06 +02:00
Ludovic Marie 4fe9d19ca3 Merge pull request #1265 from rdbtCVS/t3/revert-pr-1257-server-threading
🤖 Agent: merged after verify passed and a local run on the Mac mini. lan_e2e on the server thread passes for host and guest: boats, minecarts, frames, ladder, lightning, riding and mounts. Parity --changed passed 25 of 25, and the server-thread default is confirmed. Single player runs on its server thread again. Thanks @rdbtCVS for pushing for the root fix.
2026-10-23 03:16:47 +02:00
Ludovic MarieandClaude Opus 5.5 213cffcf3e Benchmark report: the screen read again at the end of the run
Under Wayland the title screen can come before the window is on a screen, so the
report's hardware.display stayed empty (seen end to end in a headless sway).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:13:09 +02:00
Ludovic MarieandClaude Opus 5.5 0a450d2963 Benchmarks: the desktop (RTX 4080), players' hardware breakdowns, memory in the estimate
- The desktop measured: Horizon v2026.10.22.13 against vanilla 26.3 at render
  distance 12 and 32, 3 runs each, 1280x720, VSync off, in a headless sway on
  the RTX 4080 (never on its screen). Replaces the "not yet measured" note.
- /benchmarks shows each players' group's hardware split (RAM, VRAM, drive,
  system, GPU kind) and, once the telemetry server has some, a "Players'
  hardware" table from /v1/aggregate's breakdowns. Hidden without data.
- The picker takes the memory too. Players' groups whose installs have neither
  the picked RAM nor the GPU's VRAM class count as further away, and the result
  says what they had and when a game's peak memory is a large share of the RAM.
- Each GPU in the spec table has its memory (0: shared).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:11:31 +02:00
Ludovic MarieandClaude Opus 5.5 7db6ce51a6 Docs: control Horizon with your AI agent (players), the full inventory (developers)
website/docs/agents.md, in the nav under "Going further", is the player's page.
It covers what an agent can do, connecting it from the launcher, copy-paste
config for Claude Code, Codex, Cursor and any MCP client, the game's AI Agent
page, a CLI quick reference, safety and No AI.

docs/agent-control.md is rewritten for developers:
- the surfaces, and why one MCP server covers the launcher and the game;
- the inventory, before and after, for the launcher and the game;
- No AI on every surface, and the launcher commands that stay;
- connecting from the launcher and the game;
- the design rules for a new command.

docs/launcher-commands.md lists every launcher command, its arguments and its
MCP tool. It is generated from the clap definition (cli_tools::markdown). The
launcher test the_launcher_reference_is_up_to_date fails when it is stale or a
tool is undocumented, like horizon-ctl's generated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 768f149190 Horizon Settings > AI Agent: turn agent control on, see who is connected, copy the details
Horizon Settings gains a row: "AI Agent..." and "No AI: Off". No AI hides the
AI Agent page and closes the control socket at once.

The AI Agent page has an Agent Control switch (horizon-extras.txt
agentControl, off by default): on, the game the player starts opens the
default control endpoint, so horizon-ctl and MCP clients reach it without
--control. Turning it off closes the endpoint at once; dropping a control
server now also stops its listener thread. The page shows whether an agent
is connected, when it last sent a command and the socket, and Copy
Connection Details copies the socket path, a horizon-ctl line to try and the
MCP config for this game.

agent_control_e2e.py --scenario setting covers it: Agent Control on opens the
endpoint without --control, the pages list their widgets, Copy works, and
turning the switch off removes the endpoint while the game keeps running.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:04:19 +02:00
Ludovic MarieandClaude Opus 5.5 75d818fad6 Telemetry: the screen also when Wayland names it late
Right after a window is made, Wayland doesn't say which screen it is on, so
current_monitor() is None and display was missing (seen end to end in a headless
sway). Fall back to the primary, else the only, monitor, and read it again when a
session ends; agent control's status (the benchmark report's display) uses the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:55:38 +02:00
Ludovic MarieandClaude Opus 5.5 3bfb345714 horizon-ctl options: the agent page (Horizon Settings > AI Agent)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:47:42 +02:00
Ludovic MarieandClaude Opus 5.5 51be242af0 Launcher Settings: Connect an AI agent, and the No AI switch
Settings gets an AI group with the No AI switch (horizon_core::no_ai, the
same switch the game reads) and, while it is off, a Connect an AI agent
section that replaces the old command-line note: a picker for Claude Code,
Codex, Cursor or any MCP client, the ready-made config with a Copy button,
the client's own add command (claude mcp add / codex mcp add) shown and run
only when the player clicks Add, and Test the connection, which starts the
MCP server as a client would and says how many tools answered. Client CLIs
are found on the PATH or in the folders installers use, since a launcher
started from the desktop gets a short PATH on macOS.

horizon-launcher agent-config --test runs the same test; the launcher
harness checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:35:09 +02:00
Ludovic MarieandClaude Opus 5.5 ef792ec3b9 Launcher MCP: every CLI command is a tool of the same name, plus settings and agent-config
The MCP tools are generated from the CLI's clap definition (cli_tools.rs):
mods_search is horizon-launcher mods search, with the same arguments, and
a call runs that command with --json. So the mod browser, modpacks, hosted
servers, settings, game arguments, data collection, desktop entry and the
benchmark are all reachable, and new CLI commands become tools by
themselves. Destructive ones need confirm: true and carry destructiveHint.
The session keeps play, server (with server_command/log/stop),
account_list and account_sign_in in-process, renamed after the CLI
(launch_client is play; play takes join for an invite code).

New CLI commands: settings [KEY VALUE] (selected version, folders,
install-new-versions, opening animation) and agent-config, the MCP config
for Claude Code, Codex, Cursor and any client. play takes --world and
--creative. Under No AI, mcp and agent-config are hidden and refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 684ec5357e agent_control_e2e manage: close the options with close
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 b62a866f39 agent_control_e2e manage: the player's position is under player
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 a6e3fca1dd horizon-ctl: interact-entity beside hold, away from the logs arm
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 bcbf860aaa horizon-ctl: every player action in the one command table, with a generated reference
The table gains what only the raw socket could do (options pages, connect
and invite codes, disconnect, LAN, Play with Friends, books, signs, mount
inventories, effects, xp, enchant, potion, fill, containers) and new
commands: worlds, world-rename, world-backup, world-delete --confirm (the
Select and Edit World screens, from the title screen only), servers (the
Multiplayer list) and interact-entity (right-click an entity). status,
mouse, pad, release, bench and stats become MCP tools; summon takes a
position. Destructive and network tools carry MCP annotations.

docs/horizon-ctl-commands.md is generated from the table
(horizon-ctl help --markdown) and a test fails when it drifts. The
agent_control_e2e manage scenario exercises the new commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 b117930ae4 No AI: one switch that turns off agent control (socket, horizon-ctl, both MCP servers)
horizon_core::no_ai reads and writes the noAi line of the game's
horizon-extras.txt, so the launcher and the game share one switch. While
it is on the game never opens its control socket, horizon-ctl refuses every
command with exit 4 and kind denied, horizon-ctl mcp and horizon-launcher
mcp refuse to start (and refuse calls if it is turned on mid-session), and
horizon-launcher no-ai [on|off] shows or sets it. The game's extras keep
the key when they rewrite the file. Both e2e harnesses check it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:58 +02:00
Ludovic MarieandClaude Opus 5.5 a57efd4fa0 Launcher lock file: horizon-bench now depends on horizon-telemetry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:54 +02:00
Ludovic MarieandClaude Opus 5.5 7eeb7ebbc2 Merge feat/win-screen (Options test fix) into feat/truetype-fonts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:16:55 +02:00
Ludovic MarieandClaude Opus 5.5 3f58e512e0 Options test: Credits & Attribution follows Horizon's two buttons
main (#1271) pins Horizon Settings and Mods as the grid's last buttons;
this branch adds Credits & Attribution after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:16:54 +02:00
Ludovic Marie 602e86e093 Merge pull request #1132 from ludovic111/feat/switch-to-0-2
🤖 Agent: merged after verify passed. Go-day step 5 (docs/versioning.md): Ludovic said go on 2026-10-23 ("tu peux envoyer la 0.2"). The bridges are out (v2026.10.22 and launcher-v0.17.0), and the latest stable is v2026.10.22.07, built after the freeze. The launcher redesign (#1269) and the mod browser are merged.
2026-10-23 02:16:49 +02:00
Ludovic Marie 8b0152bd11 Merge remote-tracking branch 'origin/main' into sw1132 2026-10-23 02:09:13 +02:00
Ludovic Marie 1c7656ee9b Merge pull request #1269 from ludovic111/feat/design-gaming
🤖 Agent: merged after verify passed. Ludovic approved the redesign on 2026-10-23. Integration run on the desktop by integ-w3 (main + the mod browser stack + this), then main merged and re-checked on the Mac mini (launcher clippy, tests).
2026-10-23 02:08:43 +02:00
Ludovic Marie 91f42fb633 Merge remote-tracking branch 'origin/main' into l269-merge 2026-10-23 02:01:27 +02:00
Ludovic MarieandClaude Opus 5.5 828398b5ec Telemetry: every hardware fact that explains performance, broken down by class
What players send (opt-in, unchanged switches) now describes the whole machine:
CPU cores and base/boost clock, the GPU's maker, integrated or discrete and its
VRAM, the drive under the game folder (NVMe/SSD/HDD), the screen (size, refresh,
scale), on battery or mains, laptop or desktop. Perf, crash and bug items carry
them in `system`; benchmark reports in `hardware`. No serials, host or user
names, or paths. CONSENT_VERSION goes to 2, so players are asked again.

The server keeps the new fields in `systems.extra` (the table is rebuilt once
on old databases, ids kept). /v1/aggregate and /v1/benchmarks/summary add
breakdowns by RAM class, VRAM class, drive, OS and integrated/discrete GPU;
a class is published only with at least 3 different installs, never a raw value.
website/scripts/benchmarks.mjs mirrors the classes on the shared fixtures.

The summary's "not Horizon or plain vanilla" check returned None (accepted)
instead of the rejection; it now matches the site's script.

The launcher's "View what would be sent" and `data show` list every field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:59:39 +02:00
Ludovic MarieandClaude Opus 5.5 e6237aa1da Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 f582eae9f4 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 e4fb8f0f3f Merge the updated stack base (with origin/main) into fix/server-command-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 b6a018bf87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the mounts check; levels stays last (it moves
the players between dimensions, and skips itself without --server-thread).
CHECKS and CHECK_FNS: 67 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:10 +02:00
Ludovic MarieandClaude Opus 5.5 bdbe59155d Locator bar: drop the live-backdrop check main removed
#1271 removed the title's live world backdrop (backdrop_live), so the
locator bar hides on the title alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 43a6fe0f7d Merge main into #1265: mount inventories for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:53:53 +02:00
Ludovic Marie 92b0ee1347 Merge pull request #1325 from ludovic111/fix/server-mount-inventory
🤖 Agent: merged after verify passed and the local parity run (102 of 102 at a4e68e6a, posted on the PR); lan_e2e mounts passes in both modes.
2026-10-23 01:52:24 +02:00
Ludovic MarieandClaude Opus 5.5 91dc5b0f18 Merge main into #1265: riding for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:52:02 +02:00
Ludovic Marie 014fc57b97 Merge pull request #1246 from ludovic111/feat/modbrowser-modpacks-port
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:46:33 +02:00
Ludovic Marie a9ccd5458c Merge pull request #1243 from ludovic111/feat/modbrowser-project-page
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:45:07 +02:00
Ludovic Marie 3e99730b98 Merge remote-tracking branch 'origin/main' into sw1132 2026-10-23 01:43:49 +02:00
Ludovic Marie f7b6777af6 Merge pull request #1244 from ludovic111/feat/modbrowser-e2e-harness
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:40 +02:00
Ludovic Marie 00b438d0ce Merge pull request #1242 from ludovic111/feat/modbrowser-java-launcher
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:13 +02:00
Ludovic Marie 990c609269 Merge pull request #1230 from ludovic111/feat/modbrowser-java-verdicts
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:39:39 +02:00
Ludovic Marie 9cfed309ac Merge pull request #1240 from ludovic111/fix/mod-item-network-ids
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:38:13 +02:00
Ludovic MarieandClaude Opus 5.5 0ff735b664 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 2b088da069 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 5472d4c668 Merge the updated stack base (with origin/main) into fix/server-command-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 5e55ebd2a7 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the minecarts, frames, lightning and riding
checks; levels stays last (it moves the players between dimensions, and
skips itself without --server-thread). CHECKS and CHECK_FNS: 66 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 181c91d1b8 Merge feat/win-screen (now with main) into feat/truetype-fonts
Brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +02:00
Ludovic MarieandClaude Opus 5.5 906d02fff3 Merge origin/main into feat/win-screen
- game.rs: main (#1271) dropped the Horizon-theme checks from menu_blur and
  the title scroll; kept without them, with this branch's WinScreen parts
  (the credits alone blur, the title doesn't scroll under the Win screen).
  Screen keeps main's Friends and this branch's Win.
- control.rs: horizon-ctl options pages keep credits, horizon and
  shader-packs.
- options_ui.rs: Page keeps Credits and main's Horizon. The Options grid is
  main's (Horizon Settings and Mods in vanilla's Telemetry and Credits slots;
  Horizon Extras now sits inside Horizon Settings) with Credits & Attribution
  after them, laid out by this branch's row count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +02:00
Ludovic Marie 900bba1269 Merge remote-tracking branch 'origin/main' into l269-merge
# Conflicts:
#	docs/design-system.md
2026-10-23 01:31:40 +02:00
Ludovic MarieandClaude Opus 5.5 9e11394600 Merge feat/armour-trims-worn (now with main) into feat/locator-bar
No conflicts: brings #1194's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:20 +02:00
Ludovic MarieandClaude Opus 5.5 901a5b8207 Merge origin/main into feat/armour-trims-worn
hud.rs: HudAtlas::slots keeps this branch's trim size parameter, with
main's doc line (the on-screen keyboard's text replaced the Horizon menu
text, #1271).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:19 +02:00
Ludovic MarieandClaude Opus 5.5 ed76af1c11 Merge origin/main into feat/far-detail-reduced
graphics.rs: main (#1271) dropped the level key from horizon-graphics.txt;
farDetail stays after lodDistance in main's format (21 keys, 21 values).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:05 +02:00
Ludovic Marie 8500adab0c Merge pull request #1314 from ludovic111/fix/server-riding
🤖 Agent: merged after verify passed and the local parity run (102 of 102, posted on the PR); lan_e2e riding passes in both modes.
2026-10-23 01:29:03 +02:00
Ludovic Marie 8b52929723 Merge pull request #1324 from ludovic111/feat/site-live-count
🤖 Agent: merged after verify passed; the Chromium checks are in the description.
2026-10-23 01:20:57 +02:00
Ludovic MarieandClaude Opus 5.5 a4e68e6a53 Mount inventories for a server's players, and llama inventories
A server's players (single player on its server thread, LAN guests,
dedicated-server players) get the horse inventory: shift + use on a tamed
horse, donkey, mule, camel or llama, or the inventory key while riding one
(ServerboundPlayerCommandPacket OPEN_INVENTORY), opens HorseInventoryMenu
over the mount with ClientboundMountScreenOpenPacket. Clicks run on the
menu engine's new Kind::Mount (saddle and body ArmorSlots that take what
the mount may wear, the chest, AbstractMountInventoryMenu.quickMoveStack),
and the slots go back to the mount with the item's equip sound.

The client shows a server's mount screen, predicts its clicks with the
same engine and sends OPEN_INVENTORY when the inventory key is pressed
while riding. Llamas now have their inventory in the window too: the
carpet slot and a chest of `strength` columns, no saddle slot.

lan_e2e `mounts` checks it for the host and a guest, with and without
--server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:20:51 +02:00
Ludovic MarieandClaude Opus 5.5 4eb7a98330 Website: "Playing now" on the home page, from the telemetry server's live count
The home page's "At a glance" list gets a fifth row with how many play right now, read from
https://telemetry.playhorizon.dev/v1/live (3 s timeout, again every minute while the tab is
visible). It stays hidden while nobody plays or the server doesn't answer. The FAQ no longer
says the "I'm playing" ping is off: the server is live since #1276.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:19:42 +02:00
Ludovic Marie 27bf66b354 Merge pull request #1274 from ludovic111/feat/site-releases-bench
🤖 Agent: merged after verify passed; Ludovic approved the site design. The Releases and Benchmarks pages go live with this.
2026-10-23 01:14:23 +02:00
Ludovic Marie b3e9d8ebd1 Merge pull request #1276 from ludovic111/feat/telemetry-live
🤖 Agent: merged after verify passed. telemetry.playhorizon.dev is live on Railway (service telemetry in horizon-site, volume /data): /healthz, /v1/live (a test ping counted) and /v1/benchmarks/summary (CORS for playhorizon.dev) answer. The PR's local end-to-end runs are in the description.
2026-10-23 01:14:18 +02:00
Ludovic Marie baf013dd3f Merge remote-tracking branch 'origin/main' into feat/site-releases-bench 2026-10-23 01:12:59 +02:00
Ludovic Marie e310a5ca5e Merge pull request #1259 from ludovic111/feat/website-blocks
🤖 Agent: merged after verify passed; Ludovic approved the site design.
2026-10-23 01:12:08 +02:00
Ludovic MarieandClaude Opus 5.5 c7f6903144 Merge feat/modbrowser-modpacks-port (now with main) into feat/design-gaming
launcher/src/main.rs: main (#1271) removed the Game menus setting (one menu
style now) where this branch restyled Settings. Kept the Game and About
group headings, dropped the Game menus row as main does, and renumbered the
rows after it (game arguments n+4, Horizon n+5, Launcher n+6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:09:23 +02:00
Ludovic MarieandClaude Opus 5.5 05497ddd10 Merge origin/main into fix/mod-item-network-ids
No conflicts: brings main (with #1271) so CI tests this head on current main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:34 +02:00
Ludovic MarieandClaude Opus 5.5 038e52f87e Merge feat/modbrowser-project-page (now with main) into feat/modbrowser-modpacks-port
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:33 +02:00
Ludovic MarieandClaude Opus 5.5 69887b87d1 Merge feat/modbrowser-java-launcher (now with main) into feat/modbrowser-project-page
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:33 +02:00
Ludovic MarieandClaude Opus 5.5 4786d56d5b Merge feat/modbrowser-java-launcher (now with main) into feat/modbrowser-e2e-harness
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:32 +02:00
Ludovic MarieandClaude Opus 5.5 a5d68dc890 Merge feat/modbrowser-java-verdicts (now with main) into feat/modbrowser-java-launcher
No conflicts: carries #1230's merge of origin/main up the stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:31 +02:00
Ludovic MarieandClaude Opus 5.5 9ae041ccd3 Merge origin/main into feat/modbrowser-java-verdicts
No conflicts: brings main (with #1271) under the mod browser stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:30 +02:00
Ludovic Marie 63507e7c26 Merge remote-tracking branch 'origin/main' into feat/website-blocks
# Conflicts:
#	docs/design-system.md
2026-10-23 00:58:57 +02:00
Ludovic Marie 52aa397b26 Merge pull request #1271 from ludovic111/feat/vanilla-menus
🤖 Agent: merged after verify passed. Local proof on the merged head bf274d92: fmt, clippy -D warnings, client and launcher tests, parity --changed origin/main, and test_friends.py --server-thread. CI parity passed on the earlier head. Every Horizon setting is reachable from vanilla-style screens (list in the PR body).
2026-10-23 00:56:15 +02:00
levzzz 18078e058d Explosions hurt and push minecarts, so TNT minecarts chain
ServerExplosion.hurtEntities skipped minecarts: a TNT minecart's blast
never reached the carts beside it. Carts now take the explosion's damage
through VehicleEntity.hurtServer (destroyed past 40, where
MinecartTNT.destroy primes a TNT cart with a 0-39 tick fuse, as
damageSourceIgnitesTnt counts explosions) and the knockback from their
eyes. The exploding cart is the explosion's source, so it is removed
before the blast and can't hit itself. Cart ticks now look carts up by id,
since a blast can remove or prime other carts mid-loop.
2026-10-23 03:47:04 +05:00
Ludovic MarieandClaude Opus 5.5 33dcc66379 Merge main into #1265: keep the vehicle checks, take the server-thread default
lan_e2e takes main's version: #1280 kept `boats` (passing with and
without --server-thread) and added `minecarts`, `frames` and `lightning`
next to `ladder`, and main's run() already sets HORIZON_SERVER_THREAD
explicitly as this branch does. The code keeps this branch's change:
single player runs on its server thread by default again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:42:15 +02:00
Ludovic MarieandClaude Opus 5.5 525129fbe6 Riding animals for a server's players
Single player on its server thread, LAN guests and dedicated-server
players could not ride: a server player's turn had no mount input, and
its client had nothing to sit on.

The server now drives a server player's animal from that player's
PlayerInput (forward, back, strafe, jump, sneak, sprint) and rotation,
through the same tick_mount_riding the window's player uses:
- the jump bar charges per player (MountState swapped with the turn);
- sneaking gets off, with SetPassengers and the teleport beside it;
- a mount ridden by another player can't be taken or fed (isVehicle);
- an animal whose rider left stops taking its input.
The client sits where positionRider puts it on the animal it was sent,
and charges its own jump bar for the HUD.

Ridden movement (pre-existing, also in the window): Mob.setSpeed sets zza
too, so the ridden input set before the speed was lost and pigs and
striders crawled while horses walked on with no key held; the input is
now set after the speed (travelRidden). The ridden speed uses the
effective movement speed (a cold strider's modifier, potions), and a
shivering strider rides at 0.35 of it (Strider.getRiddenSpeed).

lan_e2e riding: the host and a guest saddle and ride a horse (walk and
jump), a pig and a strider with their sticks, a camel and a donkey, sit
on a llama, harness and fly a happy ghast, and get off, with and
without --server-thread. `mobs` reports ridden, controlled, saddled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:40:34 +02:00
levzzz 18bf8cd2ef Minecarts push each other in their own ticks, as vanilla does
OldMinecartBehavior.tick ends with pushAndPickupEntities: after a cart
moves, every other cart in its box (0.2 wider) gets push(cart). Horizon
pushed all pairs once before any cart moved, so carts placed inside each
other stayed exactly overlapping (push skips distances under 1e-4) and
never split apart. Pushes now run per cart, after its move, with
pushOtherMinecart's Mth sin table facing and float thresholds.
2026-10-23 03:40:19 +05:00
Ludovic MarieandClaude Opus 5.5 bf274d9254 Merge origin/main into feat/vanilla-menus
With #1285 Play with Friends works on the server thread: friends_view
offers sharing there, so the pause menu's Friends icon is active on both
paths and inactive only on another server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:04:42 +02:00
Ludovic Marie 07e7179773 Merge pull request #1285 from ludovic111/fix/friends-server-thread
🤖 Agent: merged on verify plus local proof. tools/test_friends.py passes with the host on its server thread and in the frame (code, self-hosted relay and short code, stop). Parity --changed: 22/0. Whichever of #1271 and this lands second drops #1271's server-thread greying of the Friends icon.
2026-10-22 23:56:10 +02:00
Ludovic Marie a161487994 Merge pull request #1280 from ludovic111/fix/1222-server-vehicles
🤖 Agent: merged on verify plus local proof, since CI parity is queued behind the Mac mini runners. Parity --changed origin/main on this head: 102/0. lan_e2e passes with and without --server-thread, for the host and for a LAN guest: boats, minecarts, frames, ladder, lightning. The default is unchanged here; #1265 follows once riding animals work for a server's players too.
2026-10-22 23:55:38 +02:00
Ludovic MarieandClaude Opus 5.5 8c2f683e0a Merge origin/main into feat/vanilla-menus
Conflict: #1237's weather now returns its own vertices; the title draws
none (the live title backdrop is gone).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:15:35 +02:00
Ludovic Marie 190faa4f55 Merge pull request #1268 from rdbtCVS/perf/surface-biome-cells
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 23:07:27 +02:00
Ludovic MarieandClaude Opus 5.5 725962b858 Merge feat/win-screen into feat/truetype-fonts
No conflicts: brings #1215's Screen::Win menu-blur arm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 9f54087a86 Win screen: cover main's menu-blur match (Screen::Win)
Main's Screen::blurs_background (in-world menu blur) has no arm for the
new Screen::Win. WinScreen.extractBackground fills the screen with the End
portal while the poem rolls and falls back to Screen.extractBackground
(blurred) for the credits alone, so Game::menu_blur blurs only for the
credits without the poem.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:29 +02:00
Ludovic Marie 36aa1ec0a2 Merge pull request #1237 from rdbtCVS/perf/cache-rain-snow-params-157e7038
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 23:00:50 +02:00
Ludovic MarieandClaude Opus 5.5 c8d8a35273 Merge feat/armour-trims-worn (#1194) into feat/locator-bar
Stacks #1197 on #1194 so it merges cleanly after it. game/packs.rs: both
PRs add one line to the resource reload; both are kept (waypoint styles,
then armour-stand trims).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:43 +02:00
Ludovic MarieandClaude Opus 5.5 65fbde0f15 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:17 +02:00
Ludovic MarieandClaude Opus 5.5 54e9379bc1 Merge the updated stack base (with origin/main) into fix/server-end-gateways
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:16 +02:00
Ludovic MarieandClaude Opus 5.5 5a2d55c863 Merge the updated stack base (with origin/main) into fix/server-command-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:15 +02:00
Ludovic MarieandClaude Opus 5.5 b6b6eb9a87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the ladder and boats checks where this branch
added levels. Both are kept, with levels still last: it moves the players
between dimensions (and skips itself without --server-thread).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:00 +02:00
Ludovic MarieandClaude Opus 5.5 6bd80fe817 Merge feat/win-screen (now with main) into feat/truetype-fonts
No conflicts: brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 3ce3fe0374 Merge origin/main into feat/win-screen
hud.rs: HudBatch keeps both new fields, end_portal (this branch) and
blur_at (main's menu blur), in the struct, its constructor and the test
batch. draw_range already draws EndPortal runs on either side of blur_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:56:56 +02:00
Ludovic Marie 8ddb74031c Merge pull request #1273 from rdbtCVS/perf/end-island-volumes
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v7: core 99 and worldgen 81 tests, 23 worldgen/density/noise parity harnesses 23/0; bit-exact by construction, with a test against the scalar path).
2026-10-22 22:52:05 +02:00
Ludovic MarieandClaude Opus 5.5 7f7a05cbd0 Play with Friends on single player's server thread
Sharing a world with an invite code refused with the integrated server
on its own thread ("open a singleplayer world first"): the window is a
client there. It now shares that server as Open to LAN publishes it:
friends_start publishes it when it isn't, the invite tunnel points at
its port, stopping sharing unpublishes it again, and the pause menu
shows the Friends entry with the players the server lists.

test_friends.py --server-thread runs the host's world on its server
thread; the host's own move and blocks go through /tp, /fill and
/setblock, which the server answers in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:49:31 +02:00
Ludovic MarieandClaude Opus 5.5 e5fbba281c Releases: a title made of issue numbers reads its line from the notes
"Horizon 2026.10.22.07: #1255, mob pushes, fix, #1222" now shows "Single player runs in the frame again
until server players have vehicles and 3 more".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:36:45 +02:00
Ludovic MarieandClaude Opus 5.5 63d59b26cf Benchmarks: the ThinkPad's runs, and Java's per-scene wins
- ThinkPad (i7-7500U, HD Graphics 620, Mesa): Horizon v2026.10.22.04 (two flights per scene) against vanilla
  26.3 (two games) at render distance 12. Java loads chunks faster there and flies the mountain faster; the
  page says so. Render distance 32 isn't published for it: neither game loaded the spawn in 5 minutes, and
  Horizon kept only 900 to 1,400 columns resident when given 25.
- "Where Java is ahead" also lists scenes where vanilla's average beats Horizon's by more than 5%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:35:05 +02:00
Ludovic MarieandClaude Opus 5.5 72b749cdb0 Merge origin/main into feat/vanilla-menus
Conflicts: the menu blur (#1249) now runs without the Horizon style check,
chat and the tab list draw under every screen as before the blur split,
and the HUD's second range draws without the removed colour grade. Play
with Friends blurs the world behind it like vanilla's other menus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 1a8dae9f21 lan_e2e boats: the pool in a stone basin, for worlds that float
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:23:32 +02:00
Ludovic MarieandClaude Opus 5.5 5d77ace9a4 Merge main into #1237: keep #1234's column table and #1238's bolt quads
#1234 replaced column_size with a LazyLock table and #1238 added the
lightning quad count to gfx.rs's max chain; this branch rewrote both
places. weather.rs keeps this branch's code with #1234's table and tests;
gfx.rs takes the max over cloud, lightning and weather quads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:22:19 +02:00
Ludovic MarieandClaude Opus 5.5 afd3874482 Lightning for a server's players: the bolt and the sky flash
With single player on its server thread the window drew no lightning,
and LAN guests and dedicated-server players never did: the server never
sent the bolt (it lived outside the tracked entities) and the client had
nothing to draw for a lightning_bolt AddEntity.

The server gives each bolt an entity id and tracks it like the other
entities; the client adds a visual-only bolt for it (LightningBolt.tick
off the server: sounds, the sky flash, the drawn bolt) and ticks bolts
and the flash in its level tick. `status` reports bolts and flashes, and
lan_e2e's `lightning` check summons one beside each player and asserts
both sides get the bolt and the flash.

lan_e2e: the vehicle checks put the players apart first, and run()
sets HORIZON_SERVER_THREAD explicitly either way, as #1265 does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:16:05 +02:00
Ludovic MarieandClaude Opus 5.5 16b7e60074 Fix #1222 at the root: vehicles for a server's players
Single player on its server thread, LAN guests and dedicated-server
players could not board, drive or break boats and minecarts, nor use
item frames and armor stands: the client sent Interact/Attack only for
mobs, the server aimed only at mobs, and nothing carried riding over
the network.

Client (remote.rs, remote_vehicles.rs):
- Interact/Attack for boats, minecarts, item frames, armor stands and
  paintings, with the hit location (Minecraft.hitResult).
- SetPassengers seats the player; a boat ticks on the rider's client and
  goes to the server as MoveVehicle and PaddleBoat every tick; a
  MoveVehicle back puts it where the server says; a minecart moves on the
  server and the rider sits in it.

Server (lan_vehicles.rs, lan_actions.rs, hub.rs):
- look_at_entity aims at every pickable object, at the client's hit point.
- The guest's vehicle is its Entity.vehicle during its turn; tick_boats
  and tick_carts leave guest-ridden vehicles to their riders.
- handleMoveVehicle with its checks (invalid values kick, too quickly,
  moved wrongly, colliding with anything new), handlePaddleBoat.
- Player.rideTick: sneaking gets out, then SetPassengers and the
  teleport beside the vehicle (dismountTo); a minecart moves with the
  rider's input; a passenger's moves only turn it.
- SetPassengers for guests through hub.riding; one rider per vehicle.

lan_e2e: boats (host and guest board, paddle, the other side sees the
boat move, sneak out, break), minecarts and frames (item frame and armor
stand) for both, passing with and without --server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:16:05 +02:00
Ludovic Marie 83fa14e51b Merge pull request #1235 from rdbtCVS/t3/cache-end-sky-mesh
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:12:48 +02:00
Ludovic Marie 7a0bdbc05e Merge pull request #1238 from rdbtCVS/t3/cache-lightning-bolt-geometry
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:12:23 +02:00
Ludovic Marie 67277d4571 Merge pull request #1234 from rdbtCVS/t3/cache-column-size-table
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:10:50 +02:00
Ludovic MarieandClaude Opus 5.5 d3bad12383 Merge main into #1268: keep the End shortcut around the cached surface pass
#1264 wrapped build_surface in `if !self.identity_end_surface(pos)` where
this branch adds the uniform_biomes field to the same SurfaceQueries. Kept
the condition with the new field inside it; both tests are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:04:39 +02:00
Ludovic Marie a9682c5554 Merge pull request #1264 from rdbtCVS/perf/end-identity-surface
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:02:53 +02:00
Ludovic Marie 663b11d8dd Merge pull request #1251 from rdbtCVS/t3/fix-firework-tooltip-display
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:01:25 +02:00
Ludovic Marie 4779b48d2b Merge pull request #1250 from rdbtCVS/t3/fix-slash-command-autocomplete
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:01:00 +02:00
Ludovic Marie 01f1a0360d Merge pull request #1249 from rdbtCVS/t3/fix-menu-background-blur
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:59:29 +02:00
Ludovic Marie dcb3749ed1 Merge pull request #1248 from rdbtCVS/t3/fix-block-place-animation
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:57:58 +02:00
Ludovic Marie 96a031792b Merge pull request #1247 from rdbtCVS/t3/fix-q-drop-animation
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:56:24 +02:00
Ludovic Marie 08a73694ab Merge pull request #1245 from rdbtCVS/t3/fix-instant-mined-block-delay
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:55:59 +02:00
Ludovic Marie fb7665d88e Merge pull request #1231 from rdbtCVS/perf/saved-light-loading
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:54:19 +02:00
Ludovic MarieandClaude Opus 5.5 9e1c3ec860 perf_bench: the readiness gate only with a chunk target; a test for the quiet start
Without min_chunks (the old call) wait_loaded behaves as before, which tests/test_perf_bench.py's
old-builds case relies on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:51:16 +02:00
Ludovic MarieandClaude Opus 5.5 be16401f90 Telemetry: send to telemetry.playhorizon.dev, a benchmarks summary, the Railway config
- DEFAULT_ENDPOINT is https://telemetry.playhorizon.dev. The heartbeat, the launcher's live count and the
  outbox upload all go there; HORIZON_TELEMETRY_URL still names another server, and HORIZON_TELEMETRY_URL=off
  sends nothing at all. CI workflows and the harnesses that start the game or the launcher set off. Consent
  is unchanged: the four switches stay opt-in, only the anonymous ping is always on.
- GET /v1/benchmarks/summary: players' benchmark uploads grouped by GPU, CPU, render distance and game, groups
  of 3+ installs only, medians per install then across installs, no install ID, day, OS version, driver,
  memory or screen. Cached 10 minutes, CORS for the website. Same rules as website/scripts/benchmarks.mjs,
  tested on the same fixtures; a deleted install leaves its groups at once.
- /v1/aggregate no longer lists the newest 50 raw benchmark reports (hardware, driver, day, one install each):
  uploads are published only through the summary.
- Behind Railway's proxy the rate limit counts X-Real-IP, else the last X-Forwarded-For entry (the one the
  proxy adds; a client could choose the first). Still in memory only, never stored or logged.
- crates/horizon-telemetry-server/railway.json: Dockerfile build from the repository root, health check
  /healthz, one replica in europe-west4 (one SQLite file on the /data volume).
- Docs: docs/telemetry.md, the server README (the summary's shape, Railway setup), the privacy page (the ping
  is on, where the data lives, how benchmark uploads are shown).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:46:32 +02:00
Ludovic MarieandClaude Opus 5.5 7eded537d6 perf_bench: wait for Horizon to actually load before timing a scene
Right after a teleport, and at spawn, Horizon v2026.10.22.04 reports nothing queued and steady chunk counts
for over a second before it starts loading. wait_loaded() took that for "loaded" and stopped the clock with
0 to 3 sections meshed (load 0.1 s), so the flights ran over empty terrain and the frame rates meant nothing.

Now the counts must change once since the wait began (or stay at the full view for 5 s), and the resident
chunks must reach 80% of the view circle (pi x RD^2), before a steady second counts as loaded.

Measured with it on the Mac mini and the ThinkPad for the website's Benchmarks page (#1274). At RD 32 a 4-thread
laptop needs --timeout above the default 300 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:45:57 +02:00
Ludovic MarieandClaude Opus 5.5 fd9e6a7e78 Benchmarks: the Mac mini's runs, Horizon v2026.10.22.04 against vanilla 26.3
Three runs of each game at render distance 12 and 32 (tools/perf_bench.py, seed 1234, 1280x720, VSync off).
Horizon's average sits at the 144 Hz display (macOS presents in step with it even with VSync off): marked as
capped on the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:43:37 +02:00
Ludovic MarieandClaude Opus 5.5 aae60c16ba Website: a Releases page, "Recently added" on the home page, and a Benchmarks page
- /releases: every game and launcher release on one page, newest first, Stable / Alpha / Launcher marked and
  filterable. The newest three are open; each shows what it added and fixed, the full notes, its downloads and
  a link to GitHub. Baked at build time, refreshed from GitHub when a newer release is out. /releases#release-<tag>
  opens one.
- Home: "Recently added" replaces the patch notes block: the last five releases on one line each and three
  highlights from what they added.
- /benchmarks: Horizon against vanilla Java Edition 26.3 measured on our machines (average FPS, 1% lows, p95
  frame time, start to world, chunk loads, memory, at render distance 12 and 32), saying where Java is ahead;
  a GPU + CPU picker that shows a measurement when there is one and otherwise a marked estimate with a range,
  from our own spec table (method explained on the page); players' results read live from the telemetry
  server's GET /v1/benchmarks/summary, falling back to the baked public/benchmarks.json, groups of 3+ installs
  only.
- scripts/benchmarks.mjs builds public/benchmarks.json from our reports (and, for tests, telemetry items) with
  the same rules as the server; fixtures shared with the server's test.
- Releases and Benchmarks in the nav and footer; docs/performance.md links the page.
- Pixelify Sans' "fi" ligature drew "fixes" as "Axes": common ligatures off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:43:36 +02:00
rdbtCVS 4e04e97565 Reuse eligible End island candidates across bounded density volumes 2026-10-22 20:29:00 +01:00
Ludovic MarieandClaude Opus 5.5 fc8643b380 Docs: settings screenshots show the vanilla Video Settings and Advanced Graphics screens
Captured on the Mac mini with the game's offscreen capture (1280x720),
replacing the Horizon menu style's Video page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 954bb6a1fa Play with Friends: the pause icon takes controller focus, help moves to a Troubleshooting button
The pause menu's Friends icon is recorded as a widget, so a controller
reaches it and the control socket names it. The sharing screen's long help
URL ran off the screen: a Troubleshooting... button beside Done opens it,
and the hint drops out when it would reach the footer. The graphics docs
say what the two Horizon screens hold (vanilla's own settings stay on Video
Settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 042adc674a One menu style, vanilla's: Horizon's settings join vanilla's Options
Drop the Horizon menu style (options_horizon.rs, front.rs, the live title
backdrop and its post grade, the launcher's menu-style setting, CLI and MCP
tool). Every screen is vanilla's. Horizon's own settings hang off vanilla's
Options the way mods add theirs: Horizon Settings... and Mods... take the
Telemetry/Credits slots, and Horizon Settings opens Graphics, Advanced
Graphics, Shader Packs, Controller Settings, Extras and Mods, each a vanilla
options list with tooltips. Play with friends moves behind vanilla's Friends
buttons: the pause menu's opens Play with Friends (share, copy, stop), the
title screen's opens Join a Friend, and Direct Connection takes a full code.

Old files load as before: horizon.txt's menuStyle line and
horizon-graphics.txt's level line are ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 91d89b4abf Java shader packs: modf into a vector component works in an initialiser
The comma form of modf (`v.y = trunc(x), x - trunc(x)`) printed without
brackets in a declaration's initialiser, which naga's GLSL front end then
read as a second declarator. A unary + around it has glsl-lang print the
brackets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:56:08 +02:00
rdbtCVS cd494e74ba Cache uniform resident biome cells during surface generation 2026-10-22 19:45:38 +01:00
Ludovic MarieandClaude Opus 5.5 f1f8170c58 Launcher: the offline name without its key, and Home before any version
The game keeps the offline name as "name:Player12345" in horizon-player.txt;
the top bar and the friends-only server list showed the whole line. Both
now read the name after the key.

With no version installed, Home has no landscape: its title starts below
the top bar like the other pages, and the sidebar says "No version
installed" instead of "Needs preparing".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:33:35 +02:00
Ludovic MarieandClaude Opus 5.5 700c9ec8cd Merge main into #1264: keep the --snapshot usage line
main's warmed-timing change and this branch both edited the usage comment
at the top of examples/chunkgen_bench.rs. Kept this branch's line, which
adds [--snapshot PATH]; no code change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:29:09 +02:00
Ludovic MarieandClaude Opus 5.5 60fc787288 Java shader packs: Photon, Chocapic13' and MakeUp Ultra Fast load
The translator now legalises what these packs write and naga refuses:
lookups without a level outside fragment shaders become textureLod at
level 0; struct varyings travel as one varying per field; textureGather
is emulated with texelFetch; a bool given to a numeric constructor
becomes cond ? 1 : 0; `const in` parameters lose the const; array fields
of private structs get their natural stride (naga lays every struct out
as std140); modf into a vector component assigns it instead of passing a
pointer. shaders.properties keeps the empty lines the preprocessor
leaves, so Photon's `) \` before `#else` ends its value.

real_packs_load now also parses every translated program again as WGSL,
which is what wgpu is given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:24:55 +02:00
rdbtCVS 35ffbebcdd Revert #1257: keep the integrated server thread enabled by default
Revert commit cdaf1d79a0, preserving the ladder regression check added afterward.

Server-player bugs also affect dedicated servers and Open to LAN. Running single player in the frame only hides those bugs there; keep the shared server-thread path as the default and fix the underlying issues.
2026-10-22 19:18:03 +01:00
Ludovic Marie 1ec2762fe6 Merge pull request #1263 from ludovic111/fix/1255-ladder-fall
🤖 Agent: merged on verify plus local harness proof (CI parity is queued behind about 15 runs). The new lan_e2e ladder check: on main, ten blocks of ladder took the guest from 20 to 13 health; with the fix it lands at full health. Main was merged in, keeping both the ladder and boats checks.
2026-10-22 20:14:47 +02:00
Ludovic MarieandClaude Opus 5.5 2f8d913b85 Launcher: long lists draw only the rows near the screen
Versions and Patch notes list a hundred releases, and every frame laid out
and painted all of them. Rows more than a screen away from the viewport are
now empty blocks as tall as when they were last drawn (cull.rs), so the
page keeps its length; a row being installed or showing an error is always
drawn. Scrolling Versions on the Mac mini: median frame 8.2 -> 7.0 ms.

HORIZON_LAUNCHER_PROJECT=<slug> opens a project's page on the Mods page,
for screenshots, as HORIZON_LAUNCHER_PAGE opens a page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:14:29 +02:00
Ludovic Marie 6c4309d36f Merge pull request #1205 from pirut/claude/upstream-mob-pushes
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:11:51 +02:00
Ludovic Marie 95d0a06ba5 Merge pull request #1228 from ItsPotatoTime/fix/entity-metadata
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:05:23 +02:00
Ludovic MarieandClaude Opus 5.5 90afc3dbf4 Merge main into fix/1255-ladder-fall (lan_e2e: keep both the ladder and boats checks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:04:28 +02:00
Ludovic Marie cdaf1d79a0 Merge pull request #1257 from ludovic111/fix/1222-boat-interact
🤖 Agent: merged on verify plus local harness proof. It is a release-blocker fix, and CI parity is queued behind about 15 runs on the Mac mini. lan_e2e boats fails on main with --server-thread (riding None) and passes with this PR's default (in the frame, the path that was the default until #1114). fmt and clippy -D warnings pass. Server players' vehicles are tracked in FEATURES.md 4x.
2026-10-22 20:03:55 +02:00
Ludovic Marie d68ca84275 Merge pull request #1223 from rdbtCVS/perf/chunk-palette-loads
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:01:51 +02:00
Ludovic MarieandClaude Opus 5.5 270ba3fea9 Launcher: a blocky, Minecraft-like look in dark and light
Blocks palettes (dark default, light, or the system's, chosen in Settings), Pixelify Sans for
headings and controls, Pixelarticons icons, square panels with an outline, a bevel and a lip,
inventory tabs, and pixel art drawn in code: a stone texture behind the pages and a landscape
behind Home. A top bar with the pages and the account chip, and a sidebar with the instances
(Horizon shows the grass block the mod browser draws from the player's prepared textures), the
hosted servers, and the launcher's version and channel. The mod browser's pages (kinds as tabs,
project icons in slots, verdicts as badges, the project page) are restyled, not rebuilt. The
benchmark also measures scrolling the Versions list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:01:13 +02:00
Ludovic Marie 4f4dc33370 Merge pull request #1214 from pirut/claude/upstream-mojang-java
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:00:23 +02:00
Ludovic MarieandClaude Opus 5.5 59adff16ff Merge main into #1205: keep push_tests next to teams_tests and sensor_scan_tests
#1200 and the teams change appended test modules at the same place as this
branch's push_tests. Main's modules are kept as they are and push_tests
follows them unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:57:27 +02:00
Ludovic Marie 160076e15f Merge pull request #1229 from rdbtCVS/perf/aquifer-neighborhood
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:21 +02:00
Ludovic Marie 658d54e578 Merge pull request #1225 from pirut/claude/upstream-macos-pointer-lock
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:10 +02:00
Ludovic Marie 3576a7a1c0 Merge pull request #1208 from pirut/claude/upstream-perlin-floor
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:05 +02:00
Ludovic Marie 0ed1bd8deb Merge pull request #1207 from pirut/claude/upstream-feature-speedups
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:53:54 +02:00
Ludovic Marie f254871c1f Merge pull request #1206 from pirut/claude/upstream-perlin-climate
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:53:50 +02:00
Ludovic Marie 9f687dd1ec Merge pull request #1204 from pirut/claude/upstream-furnaces
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:49 +02:00
Ludovic Marie dc6b648f69 Merge pull request #1203 from pirut/claude/upstream-hoppers
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:37 +02:00
Ludovic Marie 36a5696abf Merge pull request #1202 from pirut/claude/upstream-mob-scans
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:26 +02:00
Ludovic Marie 48d6211781 Merge pull request #1201 from pirut/claude/upstream-chunk-bookkeeping
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:15 +02:00
Ludovic Marie 1ef5f8fae5 Merge pull request #1200 from pirut/claude/upstream-mob-world
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:03 +02:00
rdbtCVS 0928c83d4c Skip provably unchanged native End surface traversal 2026-10-22 18:40:01 +01:00
Ludovic MarieandClaude Opus 5.5 724f42355f Fix #1255: server players climbing down a ladder take no fall damage
Player's MoveSimulationType is AUTHORITATIVE_SIDE_AND_SERVER, so a
ServerPlayer travels on the server too, and every tick on a ladder or
vine LivingEntity.handleOnClimbable resets its fall distance. Horizon's
server only replayed the client's moves (ServerPlayer.doCheckFallDamage),
so the fall distance grew all the way down a ladder and hurt the player
where it landed: LAN guests, dedicated-server players and, while single
player ran on the server thread (#1114), the single player.

lan_e2e ladder: the guest slides down ten blocks of ladder and lands with
full health (before: 20 -> 13).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:37:21 +02:00
Ludovic MarieandClaude Opus 5.5 8b16f76b3c Section walk: no per-section frustum test in columns entirely inside
A column whose box lies entirely inside the frustum holds no section the
frustum would reject (plane distances only grow from its nearest corner
to a section's farthest, and rounding keeps the order), so its sections
skip the test. At render distance 32 most columns are past the distance
where the vertical field of view takes in their whole height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:29:54 +02:00
Ludovic MarieandClaude Opus 5.5 70c06224fe Website: the Blocks look, a blocky Minecraft-like design in Horizon's purple
horizon-theme gains the Blocks palettes (light and dark, WCAG AA on every panel and on the stone),
Pixelify Sans (OFL) and Pixelarticons (MIT); tokens.css now carries the Blocks colours, the pixel
font and the block edges. The site sets headings, navigation and buttons in Pixelify Sans, turns
the index lists into stacks of panels with an outline, a bevel and a lip, puts a stone texture
behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero, both drawn in
code by scripts/art.mjs. Light and dark still follow the system, with the toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:29:41 +02:00
Ludovic MarieandClaude Opus 5.5 c0fd8dabfd Fix #1222: single player runs in the frame again until server players have vehicles
Since #1114 single player's window is a player of its own server thread,
and a server's players can't use boats or minecarts: the client sends
Attack and Interact only for mobs (remote_start_attack, remote_use_entity),
the server aims a player's attack or use only at mobs and dragon parts
(look_at_entity), and neither side handles riding (no SetPassengers on the
client, no MoveVehicle or PaddleBoat packets). So a boat on water could not
be boarded or broken in single player.

The server thread is opt-in again (--server-thread or
HORIZON_SERVER_THREAD=1); --no-server-thread still works.

lan_e2e boats: the host right-clicks a boat on water and rides it, sneaks
out, and breaks it. Without --server-thread the host now runs the game's
default instead of forcing the frame, so the check covers what players get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:15:44 +02:00
Ludovic MarieandClaude Opus 5.5 da991045f9 Modpacks: jars only CurseForge serves are left off, "CurseForge downloads coming soon"
The pack lists them with the mods left off, the port's log and fetch label say
the same, and docs/modpacks.md no longer asks for a key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:54:33 +02:00
Ludovic MarieandClaude Opus 5.5 f926d1d2b1 Modpacks from the mod browser: ported in the same step, mods loaded together
Importing a modpack from the Mods page (or `mods install`) now ports it right away
for the selected version, so Play works straight after. port-pack leaves a
namespace two carried mods both claim to none of them (YUNG's mods all ship
`yungsapi`, which made the game drop three of them), and the launcher applies
the game's first-wins rule before it starts a pack, listing what it left off.

Measured on Vanilla Perfected, Slimes Adventure and All the Mods 10 (docs/modpacks.md):
2, 20 and 42 mods ported, every world loads, no crash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:52:12 +02:00
Ludovic MarieandClaude Opus 5.5 b1dc23551b tools: end-to-end check of a Java mod from the mod browser
mod_browser_harness.py searches, checks and installs a mod through the launcher's
mods CLI in a scratch game folder, opens a world with it offscreen, places one of
its blocks and crafts one of its recipes on a crafting table, and times the world
load with and without it. Macaw's Fences: check 4.0 s, install 3.1 s, world load
5.4 s -> 7.3 s, hedge placed, 4 crafted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:52:03 +02:00
Ludovic MarieandClaude Opus 5.5 f53998674d Launcher mod browser: project pages, search filters, project icons and gallery
A result's title opens its page in the launcher: icon, authors, downloads,
followers, licence, verdict (for a Java mod, what ports and what doesn't) and
Install, links, then Description (Markdown), Gallery and Versions tabs
(browser::details, Source::details on Modrinth's project and members).

Filters: sort (relevance, downloads, follows, newest, updated), loader, game
version, environment and categories, from the source's tag lists.

Icons and gallery images are fetched at run time over HTTPS from the sources'
image CDNs only, size-capped, decoded and scaled to PNGs in a 128 MB disk cache
(browser::images); a placeholder shows until they arrive. Vanilla's mark is a
grass block drawn from the player's prepared game files. Modrinth's logo isn't
shown: its branding is all rights reserved without written permission.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:51:56 +02:00
Ludovic MarieandClaude Opus 5.5 b8f79247b8 Mod browser: CurseForge is coming soon
Ludovic dropped CurseForge for now (its API needs a paid key). The Mods page shows
"CurseForge (coming soon)" beside the Modrinth switch, disabled and inert;
browser::sources() is Modrinth only, so no search or install goes to CurseForge,
and `--source curseforge` says it is coming soon. Text that asked for a key or
an environment variable now says CurseForge support is coming soon. Importing a
CurseForge zip or instance still works; jars only CurseForge serves are listed
as "CurseForge downloads coming soon".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:50:25 +02:00
rdbtCVS a97d8098c7 Fix firework tooltip translations 2026-10-22 17:47:29 +01:00
rdbtCVS d67779d5a1 Make completion regression deterministic and include screenshots 2026-10-22 17:42:56 +01:00
rdbtCVS e7d42dc9d0 Fix in-world menu background blur 2026-10-22 17:41:17 +01:00
rdbtCVS ecbb0368c5 Fix hand swing prediction for rejected block placements 2026-10-22 17:37:42 +01:00
rdbtCVS 15b8514584 Fix registry-backed slash command suggestions on connected clients 2026-10-22 17:37:23 +01:00
Ludovic MarieandClaude Opus 5.5 6e83037fca end sky test: as_chunks instead of chunks_exact (clippy 1.99)
Rust 1.99's clippy denies chunks_exact with a constant size
(chunks_exact_to_as_chunks), so `cargo clippy --all-targets -D warnings`
failed on this test. Same check, written as as_chunks::<4>().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:32:54 +02:00
rdbtCVS 6d36d468e7 Fix empty-hand drop animations 2026-10-22 17:29:55 +01:00
rdbtCVS d86a1b2882 Fix cooldown after instant survival breaks in multiplayer 2026-10-22 17:26:39 +01:00
rdbtCVS ca2d5e04dc Remove benchmark report from pull request 2026-10-22 17:15:46 +01:00
rdbtCVS b054c8db11 Remove benchmark report from pull request 2026-10-22 17:15:44 +01:00
rdbtCVS fba42920e4 Remove benchmark report from pull request 2026-10-22 17:15:41 +01:00
Ludovic MarieandClaude Opus 5.5 a3f81f3a30 Launcher: test a Java mod's install, dependencies, id clash and removal offline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:10:20 +02:00
Ludovic MarieandClaude Opus 5.5 240e76160b Mod content: give mod items and blocks network ids, so they don't travel as air
The game and its integrated server map items and blocks to network ids through
the registries report (registries.json), which the content root left vanilla:
a mod's item in a slot, given or picked up, reached the client as air. The
content root now rewrites registries.json too, appending the new items and
blocks after vanilla's ids in plan order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:07:16 +02:00
rdbtCVS c9c7cda779 docs: record the completed ten-agent optimization sequence 2026-10-22 17:06:09 +01:00
Ludovic MarieandClaude Opus 5.5 3530560581 Launcher: Java mods in the mod browser, checked before install; Modpacks moves into Mods
The Mods page gets a Mods tab: Fabric, NeoForge, Forge and Quilt mods for 26.3
(1.21 and later with Show everything). Each result's jar is downloaded (allowed
CDNs, checksum), checked with horizon-port-pack (Works, Partly, Doesn't run, with
what ports and what doesn't) and the answer cached per file. Install copies the
carry into horizon-mods/<id>/ and turns it on in horizon-mods.txt, with required
mods that port; libraries with nothing to port are listed as not ported; id or
namespace clashes are refused. Remove deletes it and turns it off.

The Modpacks page becomes the Mods page's Modpacks tab: imported packs first,
then the search. CLI: mods search --kind mod, mods check <project>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:24 +02:00
Ludovic MarieandClaude Opus 5.5 4a350bb0a0 port-pack carry: leave out recipes for items it didn't declare
A recipe whose result is one of the mod's own items that the carry didn't declare
(its block had no vanilla template) loaded and crafted air. Vanilla refuses a
recipe for an unknown item; the carry now moves such recipes to outside/ and lists
them (recipes_unregistered), and check says "N recipes for left-out blocks".
Found by the mod browser's end-to-end run on Macaw's Fences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:23 +02:00
Ludovic MarieandClaude Opus 5.5 da996b9fd1 port-pack: check one mod jar (Works, Partly, Doesn't run) from its content and classes
`horizon-port-pack check <jar> <out>` carries a single jar into a folder content
mod and reads its class files (constant pool, superclass, declared methods) for
what the carry can't take: block entities, menus, entities, blocks and items with
behaviour of their own, recipe types, worldgen code, Mixins, game events. The
verdict and the lists of what ports and what is skipped go to check.json, for the
launcher's mod browser to show before install.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:23 +02:00
Ludovic Marie 022600481e Merge pull request #1236 from ludovic111/feat/microsoft-coming-soon
🤖 Agent: merged. Microsoft sign-in shows "Coming soon" while builds have no client ID (Ludovic, 2026-10-22). verify passes; launcher tests 96+14+1.
2026-10-22 17:58:51 +02:00
Ludovic MarieandClaude Opus 5.5 abb7a97136 Animated sprites on the GPU: frames uploaded once, one dispatch per mip level
Every animation frame's mip chain goes to video memory once, at the
sprite's own size, as vanilla keeps its animated sprites' frames on the
GPU. A tick that changes sprites writes one 20-byte job per changed
sprite, and a compute dispatch per mip level repeats the chain into the
array's larger layers and mixes interpolated frames
(animate_sprite_interpolate), instead of blending on the CPU at the
array's size and copying each sprite level by level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:56:43 +02:00
rdbtCVS fa14833b41 perf(client): retain seeded lightning bolt geometry 2026-10-22 16:56:23 +01:00
rdbtCVS e4ea9e0199 Cache weather column parameters and animate atlas UVs in shader 2026-10-22 16:54:09 +01:00
Ludovic MarieandClaude Opus 5.5 4ad6bb18ef Natural spawning: resolve the entry's type once, skip the mobsAt recheck where it was picked, measure only nearby players
spawn_category_for_position looked up the picked entry's type twice per
attempt and asked mobsAt again at the position it had just picked the entry
from. The type is now resolved when the entry is picked, and the recheck is
skipped at that position (the list there holds the entry). The nearest player
distance only feeds comparisons with 24 and the despawn distances (128 at
most), so an attempt near its start measures only the players that can be
within 128 blocks of it; farther ones read as infinitely far, which every check
answers the same. The water-ambient biome tag is read only for that category.
Same lists, same random draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:54:06 +02:00
Ludovic MarieandClaude Opus 5.5 93bde90aaf Launcher: Microsoft sign-in says "Coming soon" while the build has no client ID
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:51:51 +02:00
rdbtCVS 92b8cd692d perf(client): retain the End sky mesh on the GPU 2026-10-22 16:50:29 +01:00
rdbtCVS 05400438c7 perf(client): cache weather column direction table 2026-10-22 16:44:32 +01:00
Ludovic MarieandClaude Opus 5.5 5b44452284 fix(protocol): read PARTICLES as a list; keep every particle's data
Review fixes on top of the particle serializers:

- PARTICLES (17) is List<ParticleOptions>: a VarInt count, then each
  particle. Reading it as one particle only worked for empty lists, so a
  living entity with effect particles (LivingEntity.DATA_EFFECT_PARTICLES)
  misread everything after it. Value::Particles holds the list.
- Item particles use ItemStackTemplate.STREAM_CODEC (item, count, patch),
  not the optional slot codec.
- Vibration keeps its position source (block position or entity id and
  y offset) so it writes back the bytes it read; geyser data keeps its
  Int exactly instead of going through f32.
- Enchantment names come from the server's synced registry, not the
  builtin report (enchantment is data-driven).
- Armor stands: 26.3's DEFAULT_DEPTH_CLEAR_VALUE is 0.0 (bytecode
  dconst_0), so a marker's empty box falls back to a range of 4 and a
  visible marker stand draws; restore that. Holograms stay hidden through
  the invisible flag.

Test: particles_round_trip covers every data shape, a PARTICLES list and
the entry after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:35:57 +02:00
Ludovic Marie 34c3c1cffe Merge pull request #1232 from ludovic111/feat/alpha-channel
🤖 Agent: merged. The fast channel is now "Alpha" (Ludovic, 2026-10-22); old "nightly" / beta_builds settings still read as Alpha. verify passes; launcher tests 96+14+1, release tools' Python tests 27, website build OK.
2026-10-22 17:25:47 +02:00
rdbtCVS 756cf4af07 bench: measure read-only saved chunk loading 2026-10-22 16:18:40 +01:00
rdbtCVS 0eaa13020c perf: load uniform saved light without expanding nibble layers 2026-10-22 16:18:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 5eb224a909 Rename the Nightly update channel to Alpha
The whole of Horizon is in beta, so the channel that installs every build as
soon as it is built is called Alpha; Stable keeps its name. The launcher's
Settings, the update-channel command, the MCP tool, the release notes footer
and the docs say Alpha. update_channel is saved as "alpha"; "nightly" and the
old beta_builds switch still read as Alpha.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 17:17:33 +02:00
Ludovic Marie 15b6313292 Merge pull request #1226 from ludovic111/docs/releases-channel-posts
🤖 Agent: merged (docs only; verify passes).
2026-10-22 17:04:07 +02:00
rdbtCVS a648622b92 bench: report exact warmed generation wall timings 2026-10-22 15:46:40 +01:00
rdbtCVS 97edfec914 perf: reuse decoded aquifer neighborhoods during terrain fill 2026-10-22 15:46:40 +01:00
Ludovic Marie ca125cb028 Merge pull request #1227 from ludovic111/docs/website-current
🤖 Agent: merged (docs/website only, skip-release; verify passes).
2026-10-22 16:33:54 +02:00
Ludovic MarieandClaude Sonnet 5.5 fe5e20507a Website: docs match what ships (Mods page, shader packs page, dynamic lights, LAN workstations); the live ping is off
The Mods page (launcher 0.17) replaces "a mod browser is planned"; the Horizon
shader format is no longer called planned; Nightly-only things say Nightly.
The "I'm playing" ping has no server (DEFAULT_ENDPOINT is None), so the FAQ and
the privacy page say it is off instead of sent. The channel switch says it
needs the launcher release after 0.17. The README says what deploys the site.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 16:29:58 +02:00
Ludovic MarieandClaude Opus 5.5 41e6c3eca3 release-flow: #releases shows every Nightly silently, pings only for Stable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 16:23:59 +02:00
Ludovic MarieandClaude Opus 5.5 92d2e2b86d launcher: leave Java alone when the preparer finds one itself
With a JDK installed, the launcher passed the preparation the home of the
JDK horizon-prepare finds (javac/../..) as JAVA_HOME and on the PATH.
Found through the PATH, that is often a stub: on macOS /usr/bin/javac
gives JAVA_HOME=/usr, and `JAVA_HOME=/usr /usr/bin/javac -version` never
returns (the stub calls itself), so the preparer subprocess hung on a Mac
with Temurin installed. When horizon-prepare finds a JDK by itself, run
the preparation as before, in an unchanged environment; only Minecraft's
own Java (the official launcher's, or one downloaded from Mojang) is
passed on.

Also skip the Mojang download on x86-64 Linux without glibc's loader
(NixOS without nix-ld, musl distributions): Mojang's Java can't start
there, so say so instead of downloading 115 MB first. And keep each
runtime home once (dedup only dropped neighbours).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 16:20:42 +02:00
itspotatotimeandcairn-code ad676af5eb fix(protocol): decode particle entity-data serializers so later slots survive
Serializer 17 (particles) and 16 hit a catch-all that returned Ok(None),
which stopped EntityData::read from walking the rest of the metadata list.
On armor stands that carry a level-particle effect slot, slots 2 and 3
(CustomName text and visible flag) were silently discarded, so serverside
holograms (Gamster's social-media panels and leaderboards) rendered as
nothing.

Implement both serializers as value-preserving decodes: a ParticleData
enum covering every 26.3 particle shape (state varints for ids 1/2/36/121/125,
colours, dust, dust_color_transition, effect/instant, scalar, shriek, trail,
item, vibration and geyser variants) backed by a static minecraft:particle_type
id table, with write_value arms for round-trips.

Co-Authored-By: cairn-code (GLM-5.3-Flash Free) <[email protected]>
2026-10-22 17:15:16 +03:00
itspotatotimeandcairn-code 9f6d781782 fix(client): read empty-root component names through their extra spans
Co-Authored-By: cairn-code (GLM-5.3-Flash Free) <[email protected]>
2026-10-22 17:15:16 +03:00
itspotatotimeandcairn-code a20ebde7ab fix(client): carry every server item component into tooltips
to_stack kept only damage, dyed colour, map id and a head profile, so a server item's custom name, lore, enchantments and custom data never reached the inventory tooltip. Build the vanilla `components` compound from the decoded patch and run it through the same decoder saved item NBT uses, then show lore lines between the name and the details.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:15:16 +03:00
Claude 71edc9d41c Prepare with Minecraft's own Java 25 when no JDK is installed
On a Mac without a JDK, preparing failed with "Java 25 is needed ...
/usr/bin/javac -version printed no version: Unable to locate a Java
Runtime": /usr/bin/javac is macOS's placeholder. Mojang's Java runtimes
are whole JDKs, javac included (java-runtime-epsilon is 25.0.1 on every
platform), and the official launcher has already downloaded one for
anyone who played 26.3.

- horizon-prepare: java::find also tries Minecraft's runtimes
  (<game dir>/runtime/<component>/<platform>/<component>, jre.bundle on
  macOS) and the launcher's pick (Options::java_home), and names the
  macOS placeholder for what it is.
- launcher: when no JDK is found, downloads the runtime the version JSON
  names (javaVersion.component) from Mojang into its own game folder,
  every file checked against its sha1, executables and links as the
  official launcher lays them out. A version's own preparer binary or
  Python script gets it through JAVA_HOME and the PATH.

Checked for real on Linux: the download is 147 files, its javac 25.0.1
compiles and runs a class with --release 25.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxbYUJt4ShJFCdyLNCxF21
2026-10-22 14:12:42 +00:00
itspotatotimeandcairn-code d01327862d fix(client): hide marker stands and read server entity names
A marker armor stand has an empty box, so vanilla's shouldRenderAtSqrDistance computes a zero range and the model never draws; Horizon substituted a 4-block range, which turned the stacked marker stands a lobby uses for holograms into a visible tower. Follow vanilla and let the zero edge cull the model. Server NPCs and mobs also carry CustomName, CustomNameVisible and the shared invisible flag in entity data; Horizon only read those for armor stands. Apply them to every mob, keep the styled component for the name tag, and hide the body when the flag is set.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:11:32 +03:00
itspotatotimeandcairn-code c139387e2a fix(client): read server map art and item profiles
A server lobby draws its signs as walls of framed maps and puts player heads on its NPCs. Horizon dropped every map_item_data packet, so framed maps stayed blank, and the item component decoder rejected attack_range (30) and profile (72), which made the whole equipment and container packets that carried them undecodable. Decode both components, carry a profile into the item data the head model reads, and apply server map patches to the map texture. The map pool now holds a wall of maps so the atlas does not re-upload every map every frame.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:11:32 +03:00
itspotatotimeandcairn-code a5e0645610 fix(client): draw armor stand custom names as name tags
Holograms are invisible armor stands carrying a CustomName. Armor stands parsed neither CustomName nor CustomNameVisible, and name tags came only from mobs, so a hologram drew nothing. Read both fields from NBT and entity data, keep them across a save, and put armor stands in the name tag pass at their bounding-box height even when their model is skipped.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:09:34 +03:00
itspotatotimeandcairn-code 399382084a fix(client): show server players' and NPCs' skins
The tab list resolved skins only for listed players and never handed them to the level's player models, so every player and NPC in a server world kept the default Steve or Alex texture. Resolve every profile's textures each frame and give each player entity its skin, including entities that are never in the tab list. Player heads read the minecraft:profile component with the insecure lookup vanilla uses, so unsigned textures from offline servers draw too.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:09:34 +03:00
rdbtCVS 753c071c37 docs: record early-stopped chunk optimization attempts 2026-10-22 15:09:08 +01:00
rdbtCVS 5d82b4c999 perf: load list palettes without unpacking discarded indices 2026-10-22 14:48:08 +01:00
rdbtCVS 4e46f2a57a bench: profile chunk generation and saved chunk loading 2026-10-22 14:48:08 +01:00
Claude e01296f99b Keep the pointer locked on macOS when the game captures it
winit's macOS set_cursor_position re-associates the mouse with the
pointer (CGAssociateMouseAndMouseCursorPosition(true)), so warping to
the window centre right after the Locked grab released it again and the
pointer moved freely in first person. Warp before grabbing on macOS;
Wayland still warps after, since it moves a pointer only while locked.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0188LkUPntcZdfMF3wAqR2tK
2026-10-22 13:48:04 +00:00
Ludovic MarieandClaude Opus 5.5 ac60b337aa worldgen: chunks far apart still make a region (map past the grid)
TerrainRegion::new refused a region whose chunks bounding box passed
2^20 columns, where it took any set of distinct chunks before: the
bulk_section parity fixture puts a chunk at x = 4194304 beside the
origin and failed with InvalidChunks. Keep the grid for the usual small
box and fall back to a map past it; both refuse a position twice. The
lookup now subtracts in i64, so no coordinate can wrap into the grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:40:07 +02:00
Ludovic MarieandClaude Opus 5.5 a49cf5502d geometry: floor_i32 saturates below i32::MIN, as (int) Math.floor
Vanilla 26.3 Mth.floor(double) is (int) Math.floor(v), so a value below
i32::MIN floors to i32::MIN. The truncate-and-step version wrapped to
i32::MAX there (block_collision_scan parity failed at the chunk of
x = -2147483648.8: Rust 134217727, vanilla -134217728). Step down with
saturating_sub; the fast path is unchanged, and the test now checks
the ends of the i32 range, infinities and f64::MIN/MAX against
f64::floor as i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:37:37 +02:00
Ludovic MarieandClaude Opus 5.5 2ced9a451d Server players respawn at their point in the Nether (anchors, spawnpoint)
A server's player (a LAN guest, and since the server thread the single
player too) always respawned in the Overworld. Its respawn anchor was
dropped when its action ended, and its save kept only an Overworld point.

- The guest keeps a respawn point outside the Overworld (respawn_elsewhere,
  saved as the player's `respawn`) and has it in place while it acts, so an
  anchor it uses is its own.
- guest_respawn respawns it in that point's level, waits there for the
  chunks, then resolves the point through its block
  (findRespawnAndUseSpawnBlock): beside the anchor, using a charge, or the
  world spawn and block.minecraft.spawn.not_valid when the block is gone.
- `spawnpoint` in another level than the Overworld sets a forced point there
  (SetSpawnCommand) instead of Overworld coordinates, and a forced point that
  is no anchor holds where a player fits (isPossibleToRespawnInThis), in the
  window's own game too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 86fdc72069 Server players go through End gateways
handle_server_portal ignored End gateways, so on a server (dedicated, or
single player's server thread) no player could use one: the in-frame game's
gateway code moved only its own player.

A gateway that is not cooling down now takes a server player at once
(EndGatewayBlock.entityInside, triggerCooldown, setAsInsidePortal), within
the End, keeping its motion and look (Relative.DELTA and ROTATION). An exact
exit is reached straight away; otherwise the player waits at the destination
while it loads, and the exit is found or built there (the in-frame
resolve_gateway, shared through gateway_job).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 9fe228cd1e Server commands run in their source's level (execute in, execute at)
On a server (dedicated, or single player's server thread) each dimension is
its own level, but a command ran in whichever level was in place. So a guest
in the Nether running `execute in minecraft:overworld run setblock ...` got
"That position is not loaded", and `execute at <a player in another level>`
used the wrong level's blocks.

with_source now puts the source's level in place for the command
(CommandSourceStack.getLevel), as as_target already does for a player in
another level, and puts the old one back after. `execute at` takes the
entity's level (withLevel(entity.level())), and a player's level is the
guest's own (target_level) even while another level is in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 84171cfc94 lan_e2e portals: wait for the guest to be placed in its Nether portal
On a server the guest is held where it entered the Nether until the
chunks around the destination are lit, then placed in its portal. The
check read the guest's position 40 ticks after the dimension changed,
which on a busy machine was still the held spot, and then stepped the
guest out of a portal that wasn't there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 bd36508c29 lan_e2e levels: players in their own levels on the server thread; design note
A new `levels` check (--server-thread) runs the host and a guest in different
dimensions: execute in ... tp, mobs ticking in two levels at once with unique
ids, rejoin and save/reload with the guest in the Nether, death there, the
host through a Nether portal alone, the guest through an End portal and the
exit portal alone. All of it already works on the server thread.

docs/server-levels.md says how the server's levels map onto vanilla's, what
the dedicated server and the server thread share, the cost of an extra level
and what's left. docs/server.md no longer says players can't change dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic Marie b88218d019 Merge pull request #1209 from ludovic111/feat/update-channels
🤖 Agent: merged. Nightly/Stable update channels (Ludovic's request 2026-10-22): Settings choice, nightly train 5/20 min, one stable promotion a day at 20:00 UTC, Nightly launchers check every 30 min. verify passes on b197682d (main merged in: Settings keeps update_channel and #1185's ping_notice_seen). Ships in [email protected]; launcher.yml stays disabled until #1132.
2026-10-22 15:28:00 +02:00
Ludovic MarieandClaude Opus 5.5 a069c2716f Random ticks: ask for the next ticking section, and read a section's blocks faster
tick_chunk_random asks for the lowest randomly ticking section from a height
up, instead of asking about every section: a chunk costs one question per
section that ticks, not 24. PalettedContainer::has tests whole words for the
few matching palette indices (a field-wise zero test) instead of reading
every entry, so a section read again after an edit costs a few hundred word
operations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:24:07 +02:00
Ludovic Marie d5972490a5 Merge pull request #1193 from ludovic111/docs/modpacks-12-numbers
🤖 Agent: merged last in wave 2 (docs: modpacks-12 numbers), after the modpack PRs it describes.
2026-10-22 15:22:18 +02:00
Ludovic Marie 54f01d6bb1 Merge pull request #1192 from ludovic111/perf/linux-helpers-behind
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15): fmt, clippy, workspace + launcher tests, release build and every affected parity harness pass. main merged into the branch with the same resolution as the tested tree; verify passes on that head.
2026-10-22 15:21:27 +02:00
Ludovic Marie 44e289f919 Merge pull request #1188 from ludovic111/fix/lan-guest-workstations-8
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15): fmt, clippy, workspace + launcher tests, release build and every affected parity harness pass. main merged into the branch with the same resolution as the tested tree; verify passes on that head.
2026-10-22 15:21:15 +02:00
Ludovic MarieandClaude Opus 5.5 b197682dc8 Merge origin/main into feat/update-channels (Settings: keep update_channel and main's ping_notice_seen)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:19:31 +02:00
Ludovic MarieandClaude Opus 5.5 86e08072e4 streaming: bound ExtraCover's z range too
ExtraCover::new checked only x0/x1 against the i32 range, so two areas
near i32::MAX or i32::MIN in z built a grid whose row arithmetic
(cz + dz) overflows: a panic in debug builds, wrong cells in release.
Check z the same way, and test both ends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:14:26 +02:00
Ludovic MarieandClaude Opus 5.5 c4a12964dc Merge origin/main into perf/linux-helpers-behind (game.rs: helpers behind on every run, plus main's live-count ping)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:13:44 +02:00
Ludovic Marie 41055168af Merge pull request #1196 from ludovic111/perf/spawn-lists-borrowed
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:13:09 +02:00
Ludovic Marie 55eabf4b20 Merge pull request #1195 from ludovic111/perf/lan-entity-states
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:58 +02:00
Ludovic Marie 4332014673 Merge pull request #1187 from ludovic111/fix/world-spawn-one-worker
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:45 +02:00
Ludovic Marie f787bc9b75 Merge pull request #1186 from ludovic111/fix/secure-chat-leave-order
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:31 +02:00
Ludovic Marie 1a3fb08d68 Merge pull request #1185 from ludovic111/feat/live-player-count
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:17 +02:00
Ludovic Marie 2ae6ae2aeb Merge pull request #1184 from ludovic111/fix/dedicated-server-waits
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:04 +02:00
Ludovic Marie 90ff954f23 Merge pull request #1182 from ludovic111/fix/online-mode-log-order
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:37 +02:00
Ludovic Marie bdad451ab4 Merge pull request #1181 from ludovic111/perf/spread-chunk-packets
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:15 +02:00
Ludovic Marie ba1e7e23cf Merge pull request #1179 from ludovic111/perf/render-thread-ahead
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:03 +02:00
Ludovic Marie ec9d62858a Merge pull request #1178 from ludovic111/perf/span-cpu-time
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:51 +02:00
Ludovic Marie 631160263f Merge pull request #1177 from ludovic111/feat/formations-fountain
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:39 +02:00
Ludovic Marie badc91f94b Merge pull request #1176 from ludovic111/feat/ctov-compat-sky-view
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:27 +02:00
Ludovic Marie 554932e9fd Merge pull request #1175 from ludovic111/feat/mo-structures
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:15 +02:00
Ludovic Marie 98c77f6fc7 Merge pull request #1174 from ludovic111/fix/wayland-surface-format-test
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:03 +02:00
Ludovic Marie 6323b4a517 Merge pull request #1173 from ludovic111/fix/launcher-says-no-sounds
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:09:52 +02:00
Ludovic Marie 82122f4970 Merge pull request #1155 from ludovic111/fix/riptide-spin-attack
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:08:32 +02:00
Ludovic MarieandClaude Opus 5.5 8859d7a3a3 Merge origin/main into fix/lan-guest-workstations-8 (tools/lan_e2e.py: keep both sides' checks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:08:10 +02:00
Ludovic MarieandClaude Opus 5.5 d02b66fd9a Install page: say Nightly's 30-minute check under Stable and Nightly only
Keeps the Updating list as on main, so it merges cleanly with #1132.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:07:54 +02:00
Ludovic MarieandClaude Opus 5.5 aa65c74e0f Update channels: stable at 20:00 UTC, Nightly checks every 30 minutes
The daily promotion moves to 20:00 UTC (21:00 in Paris in winter time):
PROMOTE_AT and promote.yml's cron (20:07, retries until 21:37), with the docs
that give the time. A launcher left open on Nightly looks for new releases
every 30 minutes; Stable keeps 4 hours. The launcher README's Settings
screenshot shows the Updates choice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:57:50 +02:00
Ludovic MarieandClaude Opus 5.5 ea5994a58a Update channels: Stable (daily) and Nightly (every build) in the launcher
The launcher's "Beta builds" switch becomes an Updates choice of two channels,
stored as "update_channel": "stable" | "nightly" (an old "beta_builds": true
reads as nightly). The CLI (update-channel) and MCP (update_channel) show and
set it, and list each release's channel as stable or nightly.

Nightly ships constantly: the release train waits 5 minutes for a merge wave to
settle (20 at most) instead of 15 (an hour). Stable moves once a day:
promote.yml runs at 19:00 UTC (retries until 20:37) and promotes the newest beta
that had been out 6 hours by 19:00, so late runs and retries pick the same one.
The stalled-promotion issue waits 30 hours. Release notes, docs, the website's
install and FAQ pages say Stable and Nightly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:30:19 +02:00
Ludovic MarieandClaude Opus 5.5 7e410d93b3 Fonts: TrueType providers and the font picked by text components
Every font definition of the resources (and packs) is now loaded, not
just font/default.json, and a text component's "font" field picks one:
"minecraft:alt" draws the enchanting table's Galactic alphabet,
"minecraft:illageralt" the illager runes, a pack's own font its glyphs,
and a font that doesn't exist draws every character as the missing
glyph (FontManager.getFontSetRaw).

- font.rs: one FontSet per font id with its providers in order (space,
  bitmap, unihex, ttf) and their filters ("uniform", "jp"). Bitmap
  sheets outside the atlas (another namespace or folder) are copied into
  the glyph region when first drawn.
- TrueTypeGlyphProvider: "ttf" providers (file under font/, size,
  oversample, shift, skip; CFF fonts refused) rasterized with ab_glyph
  into the glyph region as rrrr coverage (text_intensity), metrics as
  GlyphBitmap (bearings over the oversampling, whole-pixel advances).
- text::Style gains the font id; it is parsed from JSON and SNBT
  components, pack descriptions and scoreboard number formats, inherited
  by children and written back.
- Obfuscated text picks its random glyph in the style's font.

Fixes #336

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:27:47 +02:00
Ludovic MarieandClaude Opus 5.5 4e45a3bddf Credits and end poem (WinScreen)
Leaving the End through the exit portal for the first time now rolls the
end poem, the credits and the post-credits text (WinScreen), as does a
server's WIN_GAME event; Options -> Credits & Attribution -> Credits rolls
the credits alone. The texts come from the resource root
(texts/end.txt, credits.json, postcredits.txt), so packs can replace them.

- win_screen.rs: vanilla's line building (PLAYERNAME, the obfuscated
  names from LegacyRandom(8124371), wrapping at 256), scroll speeds and
  keys (Space, Ctrl, Up, Escape), the last line pinned mid-screen, the
  logo, the vignette and the backgrounds.
- HUD: Blend::Vignette (RenderPipelines.VIGNETTE) and Blend::EndPortal
  (the End portal effect as a GUI fill, the poem's background).
- Obfuscated text draws a random glyph of the same width each frame.
- seenCredits is read and saved; the player stays out of the world while
  the poem rolls and arrives in the Overworld after it.
- horizon-ctl `credits [poem] [SCROLL]` and `options credits`.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:25:47 +02:00
Ludovic MarieandClaude Opus 5.5 9d58d6f525 Random ticks: a section ticks while it holds a randomly ticking block, as in vanilla
Vanilla random-ticks a section while its count of randomly ticking blocks
and fluids is above zero (LevelChunkSection.isRandomlyTicking), and asks as
it reaches each section. Horizon ticked every section whose palette listed
such a state, even after the last one was gone, and decided before the tick's
scheduled ticks, which drew positions vanilla doesn't and shifted its
position sequence.

RandomTickSections now reads each section's blocks (PalettedContainer::has),
again only for the sections that changed, and tick_chunk_random asks for
each section when it reaches it; the game reads a chunk's sections again only
after a block changed. New harness random_tick_sections compares the
sections and positions with vanilla's real section counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:31:27 +02:00
Ludovic MarieandClaude Opus 5.5 38276fbc30 Locator bar HUD and waypoint styles
LocatorBar: when the server tracks waypoints for the player, the bar
above the hotbar shows a dot per waypoint within 60 degrees of the
view (farthest first), coloured from the waypoint's colour or its id's
hash, with the sprite its style picks by distance
(assets/*/waypoint_style/*.json, WaypointStyleManager) and the blinking
up or down arrow when the waypoint is above or below the screen. The
background is nine-sliced. Hud.nextContextualInfoState picks between
the jump bar, the experience bar (100 ticks after an experience change)
and the locator bar.

Waypoints come from ClientboundTrackedWaypointPacket on remote servers
and, on a served world, from the host's own connection in
lan/waypoints.rs; entity waypoints in view follow the entity's eyes.

Fixes #339

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:42:40 +02:00
Ludovic MarieandClaude Opus 5.5 c77863f163 Armour trims on worn armour (player, LAN guests, armour stands)
EquipmentLayerRenderer's trim layer: the pattern's
textures/trims/entity/<layer>/<asset>.png recoloured from its base
palette (palette metadata section) to the material's palette, or to the
equipment asset's trim_overrides palette (iron armour with an iron trim
uses trim/iron_darker), as PalettedTextureManager does. Each texture is
made once, on first use, into one of 32 atlas trim slots (least recently
drawn reused), and drawn over the armour layer on the player, on the
guests a LAN host sees and on armour stands.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:13:24 +02:00
Ludovic MarieandClaude Opus 5.5 938a25a712 Modpacks doc: where ATM10's data mods stand, and what is missing, ranked
Pack-wide numbers after the modpacks-12 PRs (#1175, #1176, #1177, #1191):
structures that start, processors carried and dropped per mod, content,
load time and the play-through, and the gaps ranked by how much a player
notices them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:05:53 +02:00
Ludovic MarieandClaude Opus 5.5 21baf0dc99 Screen overlays: blur and clamp from the texture's .png.mcmeta
TextureMetadataSection's blur and clamp choose how vanilla samples a
standalone texture (ReloadableTexture.setSampler). The atlas now keeps
each texture's flags and the screen-effect quads (vignette, pumpkin blur,
powder snow outline, underwater, equippable camera overlays) sample
their sprite rectangle linearly and with clamp or repeat as those flags
say, instead of always nearest and repeat.

Fixes #335

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:53:03 +02:00
Ludovic MarieandClaude Opus 5.5 8a03758c3a Load time: build a pack's content root and load its sprites and models in parallel
A content pack's first start rewrote 19 per-state tables one after another
and reopened each zip mod's archive for every file it copied (its whole
index each time). Every start then decoded the atlases' mod textures and
parsed the mods' block models one file at a time on the main thread: with
ATM10's 42 data mods that was about 70 % of the sampled startup.

- mod_content: the tables are rewritten on up to three threads, largest
  first; a zip mod's archive is opened once for all its files
  (horizon-mods' Content::reader).
- hud::atlas_sprites and ParsedModels::load list their files first, then
  decode and parse them on every core (par::par_map, results in the input
  order). The sprites come out in the order the one-by-one loop gave, and
  the content root is byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:47:03 +02:00
Ludovic MarieandClaude Opus 5.5 de8cb0dfa6 LAN guests: the lectern and the cartography table (#868)
The last two workstation menus guests couldn't use, both now on the
shared menu engine, which single player runs too:

- Lectern: LecternMenu on the engine (Kind::Lectern): the page buttons
  (1, 2, 100+) and Take Book (3, mayBuild) as ContainerButtonClick, the
  page as data slot 0 clamped to the book, the book in its one slot
  (removeItem, setItem a no-op, max 1). The host opens minecraft:lectern
  for a guest, applies the menu to the block entity (powered pulse, page
  turn level event 1043, has_book), resends changes and closes it on
  stillValid. Guests drive it from the book screen.
- Cartography table: CartographyTableMenu on the engine (Kind::Cartography,
  setupResultSlot, onTake, onCraftedBy's new map ids through a Maps env),
  replacing the old station code; guests get the same through
  guest_menu_op.
- LAN item stacks carry map_post_processing and writable/written book
  contents (they were blank for guests); map_post_processing is no longer
  saved (ItemStack.CODEC never saves it).
- A guest who enchants gets the enchant_item stat.
- Click parity: VanillaMenuOracle and menu_fixture gain both menus.
- lan_e2e: new lectern and cartography checks; enchanting checks the
  stat; server_count/server_reply ignore the guest's own operator
  broadcast echo on --server-thread; the grindstone check counts levels
  as well as points.

Fixes #868

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:35:45 +02:00
Ludovic MarieandClaude Opus 5.5 41a4ce4edb Linux: the game's workers go behind its render and server threads
On Linux an unprivileged thread can't raise itself, so #1179's render
thread hint does nothing there. The dedicated server already lowered its
helper threads instead (world generation, lighting, connection writers:
10 nice levels, or the idle class). The client now does the same, so the
world-generation and meshing workers (two pools with single player's
server thread) no longer hold the render thread back while terrain loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:05:19 +02:00
Ludovic MarieandClaude Opus 5.5 0ae1ab2e70 world_spawn harness: vanilla's server creates the world on one worker
Trees of neighbouring chunks overlap on chunk corners, and the one
decorated first wins. With several worker threads vanilla decorates in a
different order each run: seed -2080727421395798566 spawned at Y 64, 66
or 72 over 10 runs. The harness retried vanilla up to 3 times and passed
if any run matched, which still failed when none of them gave 72.

With -Dmax.bg.threads=1 (Util.maxAllowedExecutorThreads) vanilla
decorates in its scheduler's order, the one Horizon follows: that seed
gave 72 in 10 of 10 runs, as Horizon does. The other seeds that varied
are fixed too (10 of 10 each: -8171837717659076940 at 0 67 0,
-5296387317982839690 at -16 73 16, -211473008665507967 at 0 66 112).
The retries go, and vanilla's one spawn is compared exactly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:05:00 +02:00
Ludovic MarieandClaude Opus 5.5 3aa36f95ab secure_chat harness: steps end on a barrier, players leave one at a time
Three races made the two sides differ with nothing wrong in Horizon:

- Steps ended on a fixed 1.5 s sleep. On a slow run a chat or its
  broadcast (vanilla queues the chat, then the broadcast as a later
  task) landed in the next step. Each step now also ends on a barrier:
  every connected client sends `random value 1..2` twice, and waits for
  the answer, which only the sender gets and which is left out of the
  transcript.
- The client closed every socket at once, and the server logs
  "<name> lost connection" in whatever order its network threads see
  the closed connections. The players still connected now leave one at
  a time: each waits until it has been told the previous one left
  (PlayerList.remove broadcasts that after logging). The harness waits
  for Carol's line before stopping the server.
- A player the server kicks (Bob, Mallory) is logged as
  "lost connection: <reason>", or as "Disconnected" when vanilla's
  network thread closes the connection before disconnect(reason) runs.
  The reason sent is still compared in the transcript, so the log line
  no longer compares it for kicked players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:04:13 +02:00
Ludovic MarieandClaude Opus 5.5 84c7f39148 Natural spawning: borrow the spawn lists, and only the nearest distance
Natural spawning was a quarter of a 40-player server thread. Every spawn
attempt cloned the position's whole spawn list (NaturalSpawner.mobsAt) to
check the picked entry is still in it, and every group cloned it again to
pick from; mobs_at now returns the biome's or the structure's list as it is
stored (the fortress list lives in a static). The nearest player's distance,
asked on every attempt, is now the minimum of the distances instead of a
search that kept the player too. Same lists, same draws, same distances.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:58:39 +02:00
Ludovic MarieandClaude Opus 5.5 388f59aff4 Game: the ping's LAN flag follows is_published, every frame
Single player on the server thread publishes inside the integrated
server, so self.lan alone missed it; and the fps block runs only when a
frame is drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:48:02 +02:00
Ludovic MarieandClaude Opus 5.5 d0330061bd Far Detail: an opt-in Reduced level for far terrain, suggested on integrated GPUs
Reduced starts each far tile level at half the distance Full does (about a
quarter of the far quads beyond the full-detail edge, whose finest tiles stay).
Full stays the default everywhere and no preset changes it. On integrated
GPUs other than Apple's, the setting's description suggests Reduced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:45:33 +02:00
Claude 5bad717213 Perlin noise without libm floor or int-to-float conversions, same values
floor_i32 is now Java's Mth.floor (truncate, step down for negative
fractions): f64::floor is a libm call on baseline x86-64, made for every
Perlin sample. Perlin's gradient table is stored as floats (every
component is -1, 0 or 1, exact either way), so the corner dot products
skip three conversions each. About 10% fewer instructions generating
terrain; values unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 08:43:40 +00:00
Ludovic MarieandClaude Opus 5.5 dd1525f045 dedicated_server harness: wait for what each step needs, not a fixed time
The vanilla protocol client moved on after a fixed time per step. On a
busy machine, either side's server could be slower than that: the
summoned villager was not seen yet when the trade step ran, the furnace
had not smelted the ingot when it was taken, a snowball was not thrown
yet, or the pearl had not landed. Each mode failed now and then on one
side only.

Those steps now keep the same minimum time and then also wait, for up to
30 s, until it happened: the villager is tracked, the furnace's result
slot has the ingot (plus 1 s), the snowball entity was added (plus 1 s),
the pearl teleport and the health update arrived. The respawn line also
leaves out block updates: a lava fall keeps flowing while the game runs,
and how many updates arrive before the respawn depends on timing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:41:24 +02:00
Ludovic MarieandClaude Opus 5.5 14bd239141 Solid section draws grouped by arena page within 64-block bands
A list sorted only by distance switched vertex buffer every few sections:
about 1200 indirect multi-draws a frame at render distance 32, most of a
few entries, which Mesa's Intel driver unrolls on the CPU. Grouping each
band's sections by page keeps the list front to back within the band, with
one call per page and band.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:37:40 +02:00
Ludovic MarieandClaude Opus 5.5 9054acfed2 Launcher: the ping notice keeps its height; status lists the ping
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:37:31 +02:00
Ludovic MarieandClaude Opus 5.5 770809908d Render thread ahead of the workers on macOS and Windows
The client asks for its render thread what it already asks for the
server thread (vanilla's MinecraftServer.spin): user-interactive QoS on
macOS, above-normal priority on Windows, on machines with more than four
processors. While terrain loads at 32 chunks, the worker pools (two with
single player's server thread), the chunk encoders and other programs
outnumber the cores. A frame that gave its core up in queue.submit then
waited 10 to 200 ms to get one back: the long frames of #678.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:29:19 +02:00
Ludovic MarieandClaude Opus 5.5 83d001460c LAN: write each tick's entity states over the last tick's
tick_lan described every tracked entity afresh each tick: a new String for
its type, new vectors for its entity data, equipment and attributes, all
freed again at the end of the tick. The states now live in the LAN host
between ticks and each tick writes over the last tick's in place
(lan::entity::States), so those allocations are kept. The states, and so
every packet built from them, are the same as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:22:54 +02:00
Ludovic MarieandClaude Opus 5.5 8f738d35ec Live player count: an anonymous "I'm playing" ping and "N playing now"
The game sends an anonymous ping once a minute while it runs (a random
session ID kept only in memory, the version, the platform, whether a LAN
or friends world is open). The telemetry server counts sessions seen in
the last 2 minutes, in memory only, with its own per-address rate limit
and a session cap, and serves rounded counts at GET /v1/live. The
launcher shows "N playing now" on the Play page when the count is
reachable, and tells players what the ping holds on the first-run
question, in Settings, and once on the Play page for players who
answered before. docs/telemetry.md and the privacy page say the same.

Like the rest of telemetry, nothing is sent until HORIZON_TELEMETRY_URL
is set or DEFAULT_ENDPOINT gets a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:18:58 +02:00
Ludovic MarieandClaude Opus 5.5 6f1f83d07c online_mode harness: one login at a time, and vanilla's two log races
Three races made the comparison of the servers' lines about the player
fail at random:
- a login's lost connection is logged at the server's next tick, and the
  next login could start before it: each login now ends once the server
  logged its lost connection;
- vanilla's authenticator disconnects a player, then logs why, while the
  server thread may already log the lost connection: each login's lines
  are compared thread by thread;
- a player the server kicks closes the connection once it reads why; when
  that comes first vanilla logs the lost connection as "Disconnected"
  (seen once in 12 desktop runs), so a login's kick reason stands for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:08:17 +02:00
Ludovic MarieandClaude Opus 5.5 743cc8e069 Ender pearls: a server always sends the thrower the teleport
ThrownEnderpearl.onHit teleports the owner to oldPosition() (where the
pearl started its last tick) with ServerPlayer.teleport, which sends the
position packet whatever the distance. A guest's turn sent a teleport only
when the player moved more than 0.01 blocks, and the pearl took the hit
point: a pearl thrown at one's feet often lands within that, and the
dedicated_server harness saw "teleported 0 time(s)" where vanilla's server
sends 1 (about one run in four on the desktop).

The pearl now moves the player to its previous position and marks the
teleport (PlayerMotion.teleported), which the end of the guest's turn
sends even when the player stays put.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:51:36 +02:00
Ludovic MarieandClaude Opus 5.5 8291c8af5b Chunk packets get 1 ms a frame; the rest waits for the next frame
A client of a server (single player's server thread, LAN, multiplayer)
handled every received packet in the frame it arrived. Crossing a chunk
border at 32 chunks brings 25 to 60 columns at once, 0.1 ms each to
decode on an M4 Pro: a 3 to 8 ms frame each time. Once a frame has spent
1 ms on chunk packets, the events from the next chunk packet on wait for
the next frame, in order; events that have waited a tick are all handled
at once, so a slow machine's backlog never holds packets longer than
that. The status's pending_chunks counts the waiting events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:09:47 +02:00
Ludovic MarieandClaude Opus 5.5 e6b92eb4ee Structures: Formations' fountain (its pieced structure) generates
Formations' PiecedStructure starts like its SimpleStructure, then lays out
the element's jigsaw children with vanilla's jigsaw. The game's jigsaw
assembler now exposes its child layout (add_children) for a start piece
placed by another structure type; horizon:formations reads horizon:pieced
(max_depth, max_distance_from_center), which carry writes for
formations:pieced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:03:34 +02:00
Ludovic MarieandClaude Opus 5.5 2f3f1377a3 Structures: CTOV's mod-compat hooks and Repurposed's mineshaft sky view carried
ctov:modular_compat runs another processor list only when the mod it names
is loaded: carry writes that list in when the mod is ported along and takes
the hook out otherwise (142 copies in ATM10, all for Nature's Spirit).
Repurposed Structures' mineshaft_sky_view_processor (71 lists) becomes the
game's horizon:mineshaft_sky_view: nothing at or above the world's
OCEAN_FLOOR_WG height, except plain rails over a block that can occlude,
waterlogged where the world holds water. Placement contexts gain
can_occlude.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:46:42 +02:00
Ludovic MarieandClaude Opus 5.5 dbb1ca0907 LAN guests use crafters
A guest's use of a crafter now opens CrafterMenu (minecraft:crafter_3x3)
over the block entity. The host runs its clicks on the menu engine's
verified Kind::CrafterBlock (a disabled slot refuses items, filling one
enables it), sends the disabled slots and triggered as data slots
(CrafterBlockEntity.containerData), and handles
ServerboundContainerSlotStateChangedPacket (new in horizon-protocol) as
CrafterBlockEntity.setSlotState. The guest shows the server's crafter,
predicts its clicks and sends a slot toggle before the click as
CrafterScreen.slotClicked does.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 ca5404e52d Beacon on the menu engine; LAN guests use beacons
BeaconMenu now runs on horizon_client::menu (Kind::Beacon): the payment
slot (beacon_payment_items, one item), quickMoveStack and updateEffects
with BeaconBlockEntity.validateEffects, verified against the vanilla class
by test_menu_click_parity with a new SET_BEACON op (valid and invalid
powers for each pyramid level).

ServerboundSetBeaconPacket is now in the protocol. The LAN host opens
minecraft:beacon for guests with the block entity's levels and powers as
data slots (kept current while the menu is open), applies a guest's
powers to the block entity (power_select while the beam is up) and drops
the payment when the menu closes, as BeaconMenu.removed does. Guests show
the server's levels and powers and their Done button sends the packet.
Single player's beacon clicks go through the engine too.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 b5a92262c8 Enchanting table on the menu engine; LAN guests enchant
EnchantmentMenu now runs on horizon_client::menu: the item slot (one
item) and the lapis slot, its quickMoveStack, slotsChanged clearing the
offers without an enchantable item, and clickMenuButton's checks against
the costs, lapis and levels, verified against the vanilla class by
test_menu_click_parity (with its ten data slots and the offer buttons;
the oracle's player now has a profile for the invalid-button log line).
The offers and the enchanting itself, which need a level, stay with the
caller.

Single player routes the table's clicks, drags and double clicks through
the engine. The LAN host opens minecraft:enchantment for guests with
their own enchantment seed (saved with their player data), sends the
costs, seed and clues as data slots, and on ContainerButtonClick
enchants the item, takes the guest's level and lapis and gives a new
seed. Guests show the server's offers and send the button the client
menu accepts.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 3e868683ba Anvil and smithing table on the menu engine; LAN guests use them
AnvilMenu and SmithingMenu (ItemCombinerMenu) now run on
horizon_client::menu: their slot rules, createResult (the anvil's cost
data slot, repair count and rename, the smithing table's hasRecipeError),
mayPickup with the player's levels, the results' onTake and
quickMoveStack, verified against the vanilla classes by
test_menu_click_parity with repairs, merges, renames (a new RENAME op,
AnvilMenu.setItemName) and the player's levels. The harness found the
anvil treating only enchanted books as books (vanilla: anything with
stored enchantments), now fixed, and the grindstone's and anvil's result
slots now count for a double click's pick-all as in vanilla.

Single player routes both screens' clicks, drags and double clicks
through the engine; the anvil's wear and level events 1030/1029, and the
smithing table's 1044, play with vanilla's random pitch.

The LAN host opens minecraft:anvil and minecraft:smithing for guests,
handles ServerboundRenameItemPacket (new in horizon-protocol), charges the
guest's levels and wears the anvil; guests send the rename box as
AnvilScreen.onNameChanged does and show the server's cost. The control
socket's `type` now reaches the anvil's rename box.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 4fecf43d28 Grindstone on the menu engine; LAN guests use grindstones
GrindstoneMenu now runs on horizon_client::menu (Kind::Grindstone): its
input filter, computeResult, the result's onTake (both inputs cleared,
the experience sum handed to the caller) and quickMoveStack, verified
against the vanilla class by test_menu_click_parity with enchanted,
damaged and cursed tools and books.

Single player routes the grindstone's clicks, drags and double clicks
through the engine and rolls the orbs and level event 1042 (now playing
block.grindstone.use) where the result is taken. The LAN host opens
minecraft:grindstone for guests on the same engine and awards the orbs at
the block; guests read enchantments, stored enchantments, repair cost and
custom names from the server's stacks, and the host now sends repair
cost.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 c8f02d91b7 Stonecutter on the menu engine; LAN guests use stonecutters
StonecutterMenu joins horizon_client::menu (Kind::Stonecutter): the input's
recipe list (slotsChanged / setupRecipeList), selectedRecipeIndex as its data
slot, clickMenuButton (an invalid index is taken too), the result's onTake
(one input used up, awardUsedRecipes, the take sound) and quickMoveStack with
the leftover of a result move dropped. test_menu_click_parity compares it
with vanilla's StonecutterMenu, recipe buttons included.

Single player's stonecutter screen now clicks through the engine (drags and
double clicks included), and its recipe grid calls clickMenuButton as
StonecutterScreen does. A LAN guest's stonecutter opens minecraft:stonecutter
on the host's engine, as the loom does (#868).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 cd37ea98a0 LAN guests use looms: workstation menus on the host's menu engine
A guest's use of a loom opens LoomMenu (minecraft:loom) for the guest. The
host runs the guest's menu on the shared menu engine (horizon_client::menu,
Kind::Loom, as single player does): clicks, the pattern buttons
(ServerboundContainerButtonClickPacket), the content and data slot sync after
each, the take-result sound at the loom, stillValid (the block gone or out of
reach) and clearContainer when it closes or the guest leaves. The guest opens
the loom screen for the server's menu, predicts its clicks and buttons on the
engine and sends them, runs the menu's client-side slotsChanged on the
server's slots and reads the selected pattern from the data slot.

The framework (game/lan_stations.rs, game/remote_stations.rs) takes the other
workstations one by one (#868).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 255f1960ee Structures: Mo' Structures generate (jigsaw with its terrain check, config spacing, exclusion by spread)
Carry maps mostructures:generic to vanilla's jigsaw with horizon:start_checks
(terrain_radius 2, terrain_range = heightRange) and mostructures:type to a
random spread with the config's default spacing and separation and its sets
to avoid as horizon:exclusion_zone. The game's exclusion zone gains
"their_zones": false, which tests the avoided sets by spread and frequency
alone, as ModStructurePlacement does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:33:20 +02:00
Ludovic MarieandClaude Opus 5.5 30a2cc7c61 Wayland: pin the 8-bit surface choice with a colour-managed compositor's list (#1007)
#1007's white overlay on Hyprland is #1075's washed-out swapchain: a
Wayland compositor with colour management makes Mesa list Rgba16Float
first, and the first non-sRGB format was taken. Reproduced on a real
GPU (Mesa 26.2 ANV, Intel HD 620) under headless weston 16: the old
choice washes the frame out, main's Bgra8Unorm doesn't. This test keeps
that list, as wgpu reports it, choosing an 8-bit format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:26:52 +02:00
Claude bc9e5afacc Faster feature decoration: region grid, ore column fast path, lazy biome cache
TerrainRegion indexes its chunks through a grid over their bounding box.
OreFeature asks the world whether any column under its blob reaches the
floor heightmap in one call, and tracks tested positions in a bitset.
BiomeTemperature no longer preallocates its map and queue. Same output.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 06:26:36 +00:00
Ludovic MarieandClaude Opus 5.5 16738c9f26 Launcher: say when a prepared version has no sounds (#1035)
A version prepared while the sound copy failed (Mojang's republished 26.3
asset index, #1076, fixed in #1091), or one that reused such a version's
resources when installed, stays silent and showed as plain Prepared. The
Play page now says it is prepared without its sounds and makes Prepare
again the visible fix; versions --installed and the MCP list say so too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:10:31 +02:00
Ludovic MarieandClaude Opus 5.5 e70b520e5d Render thread ahead of the workers on macOS and Windows
The client asks for its render thread what it already asks for the
server thread (vanilla's MinecraftServer.spin): user-interactive QoS on
macOS, above-normal priority on Windows, on machines with more than four
processors. While terrain loads at 32 chunks, the worker pools (two with
single player's server thread), the chunk encoders and other programs
outnumber the cores. A frame that gave its core up in queue.submit then
waited 10 to 200 ms to get one back: the long frames of #678.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:57:47 +02:00
Ludovic MarieandClaude Opus 5.5 c2b7f3a4ea Slow-frame and slow-tick lines show the CPU time of each span
HORIZON_SPIKES spans keep the thread's CPU time beside their wall time,
and the slow-frame line gives the render thread's CPU time in the frame.
A span that mostly waited (for the GPU, a lock or a core) prints both
(`submit 30.2/0.1`), so a frame that waited reads apart from one that
worked. Found the cause of #678's long frames: the render thread losing
its core in queue.submit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:55:54 +02:00
Ludovic MarieandClaude Opus 5.5 8e700aa683 Riptide: run vanilla's auto-spin attack after the launch
Releasing a Riptide trident now starts LivingEntity.startAutoSpinAttack(20,
8.0): for 20 ticks the player takes the SPIN_ATTACK pose (0.6x0.6, eye
height 0.4), hits the first living entity its swept box touches for the
spin's damage and bounces back (velocity x -0.2), and stops on a horizontal
collision. The player model spins (setupRotations: rotX(-90 - xRot),
rotY(age * -75)), SpinAttackEffectLayer draws the riptide boxes, and the
first-person hand uses ItemInHandRenderer's spin transform. LAN guests see
the host's spin through living flag bit 4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:43:55 +02:00
Claude dfe795bc09 Faster Perlin volumes and climate search, same results
PerlinNoise::add_volume worked out each Y coordinate's cell, offset and
fade once per X/Z column. Every column walks the same Y coordinates, so
they are now computed once per volume. The arithmetic is unchanged, so
the output is bit for bit the same.

ClimateIndex kept its tree as nodes with a Vec of child ids each. The
built tree is now renumbered breadth first into flat arrays, so a
branch's children are one contiguous run. The search visits the same
nodes in the same order, so ties resolve exactly as before.

terrain_bench (seed 12345, 8x8 chunks, 1 thread, medians of 3 runs):
terrain 14.5 -> 12.4 ms/chunk, biomes 1.20 -> 1.06 ms/chunk.
Checked against vanilla 26.3 in-process: 2304 chunks on seeds 12345, -1
and 987654321987 identical, and the biome-source and biome-generation
parity suites (2.1M lines) match.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 02:42:52 +00:00
Claude 9019d708d0 Streaming: answer "near a player?" from a grid of the players' squares
On a server every player is an extra area, and streaming asks of every
loaded and wanted column, several times a tick (unloading, dropping
protos, finishing, decoration, the generation scan), whether one of
those squares reaches it: a scan of every player's square per question.
The squares are now one grid of distances, made again when a player
changes chunk, and in_extra reads it. Differential test against the scan
over 60 random layouts, every margin.

At 50 players, 6081 columns, 3 questions each: 0.45 ms -> 0.04 ms, plus
0.02 ms to make the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:40:47 +00:00
Claude 186777110f Hoppers: skip the item scan when no item reaches the hopper's column
HopperBlockEntity.suckInItems scanned every item entity for each hopper
that ticked without a container above. During the block-entity pass the
game now keeps the set of block columns item boxes reach; a hopper whose
column is not in it has nothing to suck, and the scan (same order, same
item taken) runs only for the rest. Items do not move within the pass:
removals leave a superset, and additions (pushed at the end with a new
id) or removing the last item rebuild the set. It is dropped at the end
of the pass.

  500 hoppers,  200 items:  1.36 -> 0.05 ms per tick
  2000 hoppers, 500 items: 13.52 -> 0.26 ms per tick
  2000 hoppers, 2000 items: 60.6 -> 3.3 ms per tick

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:40:38 +00:00
Claude cb0bbe7d19 Sculk: skip the loaded-chunk diff while no chunk came or went
sculk_sync_index collected every loaded chunk key into a set and diffed
it against the known chunks every ten ticks, thousands of keys on a busy
server. It now remembers the chunk map's membership revision and skips
the diff while it is unchanged; a replaced chunk keeps its key and was
not rescanned before either.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:06 +00:00
Claude 863a309161 Allays and piglins: hoist per-tick lookups out of the per-mob loop
tick_allays scanned every block entity of the level for each allay,
every tick, to find a playing jukebox within 10 blocks. The playing
jukeboxes are now collected once, at the first allay (no allay starts
or stops one).

piglin_pickups looked up the piglin_loved tag and hashed every item
entity's id once per piglin. Each item's flag is now computed once and
kept until an item is added (always pushed at the end under a new id)
or removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:06 +00:00
Claude 3917f35e3e Natural spawning: answer mob box queries from a grid after a few scans
GameLevel's isUnobstructed, noCollision (living shulkers) and the
spawners' entity count each scanned every mob, computing each 1.5 KB
mob's box. A spawn pass holds the game immutably, so after eight
queries it builds a grid of the mobs' boxes once and reads candidates
from it; the first queries still scan, as the build costs a few scans.

  100 box tests, 2000 mobs: 3.48 -> 0.31 ms (grid build included)

Also gives the mob_ai_probe example the new others_index field.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude a893fa6125 Villagers: count the others' POI tickets once per search
pois_in_range (AcquirePoi and the job, bed and meeting point searches)
asked, for every candidate POI in range, how many tickets it had, and
each answer scanned every other mob for villagers holding it: POIs times
mobs per search. The others' tickets are now counted into a map at the
first candidate and read from it; this villager's own memories are
still checked per POI, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 3b21a975bb Mob goals: read nearby mobs from the tick's grid in four more places
FollowParentGoal (every baby, every tick), the pufferfish's puff check,
the enderman's block placing and the iron golem's DefendVillage scanned
every mob. They now read candidates from the position grid around the
area they test (one block wider where they test positions, as a box
whose bottom face holds the position meets it), in snapshot order, and
test exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 67ca65bb3b Mob tick: drop gone mobs' guest bindings with a set, not a scan per binding
On a server every ticked mob is bound to its nearest player, and each tick
the bindings of mobs that are gone were dropped by scanning every mob for
each binding. Mob is 1.5 KB, so the scan misses the cache on every mob:
with 1000 loaded mobs that was 0.44 ms a tick, with 2000 1.86 ms; a set of
the live ids makes it 0.02 and 0.04 ms (stand-in structs of Mob's size).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 6216b84669 Mob tick: build the mob world once a tick, index the snapshot
tick_creature_entities called mob_world_base for every mob. That rebuilt
the player view (its allies set scans every mob UUID) and, with any
scoreboard team, an owned copy of every mob's team, each a clone of the
team's player set: cubic in the mobs on a team. The base is now built
once and cloned per mob, with the teams borrowed behind an Arc.

  100 mobs, half on a team:  37.7 ms -> 0.06 ms per tick
  500 mobs, half on a team:  4297 ms -> 0.31 ms per tick

MobWorld::seen looked mobs up by scanning the snapshot; the mob ticks
now pass an id index (first slot wins, as the scan did): 2000 lookups
over 2000 mobs 1.09 -> 0.01 ms. The nearest-living-entity sensors, the
look-at goal, the ravager's roar and MobWorld::nearby read candidates
from the tick's position grid instead of every mob, in the same order:
2000 mobs 0.39 -> 0.11 ms per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 2ecbdd57eb Mob pushes: one list per mob once a crowd is pushing
Pushes between mobs wait in a list until their target ticks; each mob's
tick scanned the whole list, and take_mob_pushes removed each push from
the events in place, shifting the rest. In a pen where every mob pushes
its neighbours both grow with the square of the crowd per mob.

The pending pushes stay a list while short (64) and become one list per
mob past that, each in push order, so every velocity adds up the same
values in the same order. take_mob_pushes splits the events in one pass,
keeping the order of the others. A test checks velocities bit for bit
and the remaining events against the old code.

  30 mobs pushing each other:   0.11 -> 0.04 ms per tick
  100 mobs pushing each other:  1.73 -> 0.37 ms
  200 mobs pushing each other:  9.07 -> 2.61 ms

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:34 +00:00
Claude 62874d8dc4 Furnaces: look up the cooking recipe in an index, without cloning it
Every lit furnace, smoker and blast furnace called Recipes::cook each
tick, which scanned all cooking recipes (string compares and hash
lookups) and the furnace then cloned the recipe found, its ingredient's
item set included. Recipes now keeps, built at the first lookup, the
first recipe of each kind for each item, which is what the scan
returned; the furnace borrows it.

  1000 lookups over 320 recipes: 2.07 -> 0.08 ms

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:00 +00:00
Claude da314fa658 Server tick: cheaper view requeue and chunk ticket levels
Two costs that grow with players on the server thread:

- Hub::update_view rebuilt a player's chunk queue on every chunk crossing
  by scanning the queue (a Vec) once for each chunk of the view. It now
  checks a set. Same queue, same order (differential test against the old
  scan). 111 -> 16 us per crossing at view 10, 6.5 ms -> 0.13 ms at 32.
- ChunkTickets rebuilt a hash map of every ticketed chunk whenever any
  player changed chunk, and every block entity, mob and ticking chunk
  hashed into it. Levels are now a grid over the tickets' bounding box,
  with the map kept for tickets spread too far apart. At 50 players:
  build 0.23 -> 0.08 ms, lookups about 2x faster (differential test over
  200 random ticket layouts).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:00 +00:00
Ludovic MarieandClaude Opus 5.5 6127790456 Switch releases to 0.2 (merge only when Ludovic says go)
.github/release-line.txt = 0.2: the release train's next runs publish v0.2.0 (a beta, as always)
and [email protected] at once, "the first release of 0.2" (docs/versioning.md). Merge it only after
the bridges (the last dated game release, made stable, and launcher-v0.17.0) are out: the go-day
steps are in docs/versioning.md.

Also, because they say 0.2 is out: the site's FAQ explains the numbers, Install says an old launcher
updates twice, hosting.md gives the one command for a server that says it is up to date, and the
launcher's Cargo.toml says 0.2.0 so builds from source (packages) are on the 0.2 line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:24:30 +02:00
614 changed files with 55574 additions and 13011 deletions

No files matched your search

-1
View File
@@ -6,7 +6,6 @@
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/tools/release_channels.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
+1
View File
@@ -0,0 +1 @@
0.2
+9 -1
View File
@@ -18,6 +18,10 @@ concurrency:
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
group: checks-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
verify:
runs-on: ubuntu-latest
@@ -60,9 +64,13 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --workspace --doc
parity:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
+4 -3
View File
@@ -49,6 +49,10 @@ concurrency:
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
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:
decide:
runs-on: ubuntu-latest
@@ -188,9 +192,6 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
+4
View File
@@ -23,6 +23,10 @@ permissions:
concurrency:
group: nightly-audit
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:
audit:
runs-on: [self-hosted, horizon-game]
+4
View File
@@ -15,6 +15,10 @@ on:
required: true
permissions:
contents: read
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
runs-on: [self-hosted, horizon-game]
-52
View File
@@ -1,52 +0,0 @@
name: promote
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
# after the next promotion.
on:
schedule:
- cron: '13,43 * * * *'
workflow_dispatch:
inputs:
tag:
description: The beta to promote now (empty = the newest beta that is due)
type: string
default: ''
force:
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
type: boolean
default: false
dry_run:
description: Say what would be promoted, change nothing
type: boolean
default: false
permissions:
contents: write
issues: write
concurrency:
group: promote
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# The notes of a promoted release cover every pull request since the last stable one.
fetch-depth: 0
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
persist-credentials: false
- name: Promote
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force && 'yes' || '' }}
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
+27 -11
View File
@@ -1,14 +1,17 @@
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:
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them.
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago (the oldest 20 minutes ago, if merges never pause), 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.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
# MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
@@ -32,6 +35,8 @@ on:
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
cancel-in-progress: false
@@ -203,7 +208,7 @@ jobs:
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
@@ -319,6 +324,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
@@ -341,6 +347,17 @@ jobs:
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
@@ -348,16 +365,15 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
# The release and the latest at once: there is one channel (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
+4
View File
@@ -11,6 +11,10 @@ permissions:
concurrency:
group: run-parity-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
if: github.event.label.name == 'run-parity'
+175
View File
@@ -0,0 +1,175 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.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-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 |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~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`.
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):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
## Vanilla reference
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
([docs/decompilation-status.md](docs/decompilation-status.md)).
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
open `[server-thread]` issues list the gaps.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
## Docs worth knowing
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
Generated
+1
View File
@@ -1395,6 +1395,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-telemetry",
"serde",
"serde_json",
]
+3 -3
View File
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -56,8 +56,8 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
+12
View File
@@ -36,3 +36,15 @@ SOFTWARE.
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1
(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`).
## Pixelarticons
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
+2
View File
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
+51 -19
View File
@@ -16,8 +16,22 @@ pub struct Hardware {
pub cpu: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_threads: Option<usize>,
/// Physical cores.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_cores: Option<u16>,
/// Base and top boost clock, where the OS gives them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_base_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_boost_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_mb: Option<u64>,
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
/// `laptop` or `desktop`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<Gpu>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -33,6 +47,9 @@ pub struct Gpu {
pub backend: String,
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
pub kind: String,
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vram_mb: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -47,17 +64,50 @@ pub struct Display {
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
display: None,
}
}
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
struct Probes {
cores: Option<u16>,
base: Option<u32>,
boost: Option<u32>,
form: Option<String>,
}
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
if cfg!(target_os = "macos") {
let batt = output("pmset", &["-g", "batt"])?;
return Some(if batt.contains("'Battery Power'") {
"battery"
} else {
"ac"
});
}
let mut on_battery = None;
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
let read =
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
match read("type").trim() {
"Mains" if read("online").trim() == "1" => return Some("ac"),
"Battery" => on_battery = Some("battery"),
_ => {}
}
}
on_battery
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
+6 -7
View File
@@ -30,7 +30,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
# Sound (ALSA on Linux); the `audio` feature.
@@ -42,8 +44,9 @@ log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -80,7 +83,3 @@ rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
@@ -82,12 +82,19 @@ fn main() -> horizon_client::Result<()> {
let brewing = horizon_client::brewing::Brewing::load(root, &items);
// The inventory menu never reads banner patterns.
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
let names = horizon_client::creative::Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut out = String::new();
for line in input.lines() {
@@ -1,7 +1,7 @@
//! Firework tooltip companion for tools/test_firework_tooltip_parity.py: the
//! lines `item_tooltip::firework_lines` gives each stack (SNBT, one per line;
//! the `#resources` line names the prepared resources for en_us).
use horizon_client::{item_data::parse_stack, item_tooltip::firework_lines, lang::Language};
use horizon_client::{enchant::Enchantments, item_data::parse_stack, item_tooltip::firework_lines};
use std::{error::Error, fmt::Write, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
@@ -12,8 +12,9 @@ fn main() -> Result<(), Box<dyn Error>> {
.find_map(|l| l.strip_prefix("#resources "))
.map(PathBuf::from)
.ok_or("no #resources line")?;
let language = Language::load(&[&root], "en_us");
let translate = |key: &str| language.get(key).map(str::to_owned);
// Use the inventory's filtered translations so missing tooltip keys are caught.
let enchantments = Enchantments::load(&root);
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
let mut output = String::new();
for line in input.lines().filter(|l| !l.starts_with('#')) {
let stack = parse_stack(line)?.ok_or("empty stack")?;
@@ -14,13 +14,22 @@ fn main() {
for count in [0, 1, 4, 64] {
let mut slot = Some(ItemStack::new("minecraft:stone", count));
let mut dropped = 0;
let mut swings = 0;
let mut packets = 0;
for _ in 0..2 {
if let Some(stack) =
take_held_drop(&mut slot, all, mode == GameMode::Spectator)
{
dropped += stack.count;
swings += 1;
}
println!("{} {}", slot.as_ref().map_or(0, |s| s.count), dropped);
if mode != GameMode::Spectator {
packets += 1;
}
println!(
"{} {dropped} {swings} {packets}",
slot.as_ref().map_or(0, |s| s.count),
);
}
}
}
@@ -121,6 +121,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tick_frozen: false,
tick_steps: (0, 0),
collision: None,
border: &horizon_client::worldborder::WorldBorder::default(),
};
for event in hub.tick(&mut ctx) {
match event {
@@ -0,0 +1,153 @@
//! Read-only saved chunk benchmark. All timings exclude resource/corpus setup.
//!
//! load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]
//! Add --profile-worker when a sampling collector cannot sample the main thread.
//! Reports warm filesystem SaveDir::load (read, inflate, NBT, decode and light),
//! plus isolated inflate/NBT and native decode. No files in the save are changed.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
save::{SaveDir, SaveTables, decode_with},
};
use horizon_world::{nbt::Limits, region::RegionFile};
use sha2::{Digest, Sha256};
use std::{hint::black_box, path::PathBuf, sync::Arc, time::Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
if std::env::args().any(|a| a == "--profile-worker") {
std::thread::spawn(|| run().map_err(|e| e.to_string()))
.join()
.expect("benchmark worker")
.map_err(Into::into)
} else {
run()
}
}
fn run() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let usage = "load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]";
let root = PathBuf::from(args.first().ok_or(usage)?);
let dimension = PathBuf::from(args.get(1).ok_or(usage)?);
let flag = |name: &str, default: usize| -> Result<usize> {
Ok(match args.iter().position(|a| a == name) {
Some(i) => args.get(i + 1).ok_or(usage)?.parse()?,
None => default,
})
};
let rounds = flag("--rounds", 5)?;
let limit = flag("--limit", 256)?;
if rounds == 0 || limit == 0 {
return Err("rounds and limit must be positive".into());
}
let assets = Assets::load(&root)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Biomes::load(&root)?;
let tables = Arc::new(SaveTables::new(&assets).with_block_entities(&facts));
let source = SaveDir::new(dimension.clone());
let _ = source.tables.set(tables.clone());
let mut paths: Vec<_> = std::fs::read_dir(dimension.join("region"))?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<_>>()?;
paths.retain(|p| p.extension().is_some_and(|e| e == "mca"));
paths.sort();
let mut corpus = Vec::new();
'regions: for path in paths {
let mut region = RegionFile::open(path)?;
let mut positions: Vec<_> = region.chunks().map(|(pos, _)| pos).collect();
positions.sort_by_key(|p| (p.x, p.z));
for pos in positions {
let record = region.read(pos)?.ok_or("missing record")?;
let tag = record.nbt(Limits::default())?;
if decode_with(&tag, assets.registry, &biomes, Some(&tables)).is_none() {
continue;
}
corpus.push((pos, record, tag));
if corpus.len() == limit {
break 'regions;
}
}
}
if corpus.is_empty() {
return Err("no full chunks in corpus".into());
}
let mut records_hash = Sha256::new();
for (pos, record, _) in &corpus {
records_hash.update(pos.x.to_le_bytes());
records_hash.update(pos.z.to_le_bytes());
records_hash.update((record.compressed.len() as u64).to_le_bytes());
records_hash.update(&record.compressed);
}
println!(
"{}",
serde_json::json!({"corpus": dimension, "chunks": corpus.len(), "rounds": rounds,
"compressed_records_sha256": format!("{:x}", records_hash.finalize()),
"compressed_bytes": corpus.iter().map(|(_, r, _)| r.compressed.len()).sum::<usize>()})
);
// Prime SaveDir's region handles and the filesystem cache before measurement.
for (pos, _, _) in &corpus {
black_box(
source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?,
);
}
for round in 0..rounds {
let started = Instant::now();
for (pos, _, _) in &corpus {
black_box(
source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?,
);
}
let load_s = started.elapsed().as_secs_f64();
let started = Instant::now();
for (_, record, _) in &corpus {
black_box(record.nbt(Limits::default())?);
}
let nbt_s = started.elapsed().as_secs_f64();
let started = Instant::now();
for (_, _, tag) in &corpus {
black_box(
decode_with(tag, assets.registry, &biomes, Some(&tables)).ok_or("decode failed")?,
);
}
let decode_s = started.elapsed().as_secs_f64();
println!(
"{}",
serde_json::json!({"round": round, "load_s": load_s,
"inflate_nbt_s": nbt_s, "decode_s": decode_s})
);
}
if args.iter().any(|a| a == "--checksum") {
let mut hash = Sha256::new();
let mut light_loaded = 0;
for (pos, _, _) in &corpus {
let chunk = source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?;
hash.update(pos.x.to_le_bytes());
hash.update(pos.z.to_le_bytes());
light_loaded += usize::from(chunk.light_loaded);
for section in &chunk.sections {
for i in 0..4096 {
hash.update(section.get(i).to_le_bytes());
hash.update([section.light(i)]);
}
for i in 0..64 {
hash.update(section.biomes.get(i).to_le_bytes());
}
}
}
println!(
"{}",
serde_json::json!({"blocks_biomes_light_sha256": format!("{:x}", hash.finalize()),
"light_loaded": light_loaded})
);
}
Ok(())
}
+106 -4
View File
@@ -11,8 +11,41 @@ use horizon_client::{
items::Items,
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::registry::Ids;
use serde_json::Value;
use std::{fs, path::Path};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
fs,
path::Path,
};
/// The case's maps: (locked, scale) by id, and the next free id.
#[derive(Default)]
struct CaseMaps {
maps: RefCell<HashMap<i32, (bool, u8)>>,
next: Cell<i32>,
}
impl horizon_client::menu::Maps for CaseMaps {
fn state(&self, id: i32) -> Option<(bool, u8)> {
self.maps.borrow().get(&id).copied()
}
/// `MapItemSavedData.scaled` (unlocked, one scale up) or `locked`.
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
let (_, s) = self.state(id)?;
let new = self.next.get();
self.next.set(new + 1);
let made = if scale {
(false, (s + 1).min(4))
} else {
(true, s)
};
self.maps.borrow_mut().insert(new, made);
Some(new)
}
}
fn state(menu: &Menu) -> String {
let slots: Vec<String> = (0..menu.slot_count())
@@ -24,13 +57,21 @@ fn state(menu: &Menu) -> String {
.iter()
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
.collect();
format!(
let line = format!(
"{} | {} |{data} | {} | {}",
slots.join(" "),
canon_stack(menu.carried.as_ref()),
menu.drops.join(";"),
equipped.join(";")
)
);
if menu.kind() != Kind::Lectern {
return line;
}
// No inventory slots: the inventory Take Book fills.
let items: String = (0..36)
.map(|i| format!(" {}", canon_stack(menu.player_item(i))))
.collect();
format!("{line} | inv{items}")
}
fn main() -> horizon_client::Result<()> {
@@ -44,13 +85,28 @@ fn main() -> horizon_client::Result<()> {
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
let enchantments = horizon_client::enchant::Enchantments::load(root);
let names = horizon_client::creative::Names::load(root);
let case_maps = CaseMaps::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: Some(&case_maps),
};
// The mob effects' network ids (BeaconMenu.encodeEffect).
let builtin = horizon_protocol::registry::Builtin::find(root)
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok());
let effects = builtin
.as_ref()
.and_then(|b| b.get("minecraft:mob_effect"))
.cloned();
let mut out = String::new();
for line in input.lines() {
let case: Value = serde_json::from_str(line)?;
@@ -68,15 +124,41 @@ fn main() -> horizon_client::Result<()> {
.unwrap_or(name),
)
.ok_or("menu kind")?;
// [id, scale, locked] each; the level's new maps from 1000.
case_maps.next.set(1000);
*case_maps.maps.borrow_mut() = case["maps"]
.as_array()
.into_iter()
.flatten()
.map(|m| {
let id = m[0].as_i64().unwrap_or(0) as i32;
(
id,
(
m[2].as_bool().unwrap_or(false),
m[1].as_u64().unwrap_or(0) as u8,
),
)
})
.collect();
let mut menu = Menu::new(kind);
menu.creative = case["creative"].as_bool().unwrap_or(false);
menu.levels = case["levels"].as_i64().unwrap_or(0) as i32;
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
}
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
for (i, item) in case["inventory"]
.as_array()
.into_iter()
.flatten()
.enumerate()
{
menu.set_player_item(i, parse_stack(item.as_str().unwrap_or("-"))?);
}
menu.settle(&env);
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
menu.set_data(i, v.as_i64().unwrap_or(0) as i32);
}
let mut furnace = furnace_kind.map(|kind| Furnace {
kind,
@@ -100,6 +182,26 @@ fn main() -> horizon_client::Result<()> {
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundRenameItemPacket: AnvilMenu.setItemName.
"RENAME" => {
menu.rename(&env, op[3].as_str().unwrap_or_default());
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundSetBeaconPacket: BeaconMenu.updateEffects.
"SET_BEACON" => {
fn power<'a>(ids: Option<&Ids>, v: &'a Value) -> Option<(i32, &'a str)> {
let name = v.as_str()?;
Some((ids?.id(name)?, name))
}
let (primary, secondary) = (
power(effects.as_ref(), &op[3]),
power(effects.as_ref(), &op[4]),
);
menu.beacon_update(&env, primary, secondary);
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundContainerSlotStateChangedPacket.
"SLOT_STATE" => {
if (0..9).contains(&slot) {
@@ -1027,6 +1027,7 @@ fn main() -> horizon_client::Result<()> {
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
others: $snapshot,
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
@@ -1132,7 +1133,7 @@ fn main() -> horizon_client::Result<()> {
}
#[allow(unused_mut)] // filled only with the rng-trace feature
let mut traces: Vec<String> = Vec::new();
let mut pushes = Vec::new();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut born = Vec::new();
for i in 0..mobs.len() {
if mobs[i].removed {
@@ -111,6 +111,8 @@ struct World<'a> {
slime: f32,
starts: Vec<Start>,
players: Vec<([f64; 3], String)>,
/// Where the non-spectators among them are.
nearest: Vec<[f64; 3]>,
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
fn structure_spawns(&self) -> &StructureSpawns {
self.s.structures
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let mut best: Option<f64> = None;
for (p, mode) in &self.players {
if mode == "spectator" {
continue;
}
let d = [p[0] - x, p[1] - y, p[2] - z];
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if best.is_none_or(|b| d < b) {
best = Some(d);
}
}
best
fn nearest_player_positions(&self) -> &[[f64; 3]] {
&self.nearest
}
fn respawn(&self) -> Option<[i32; 3]> {
self.respawn
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
dimension_id: dimension_id.clone(),
slime: get("slime", "0.0").parse()?,
starts,
nearest: players
.iter()
.filter(|(_, mode)| mode != "spectator")
.map(|(p, _)| *p)
.collect(),
players,
respawn,
unloaded,
@@ -38,12 +38,19 @@ fn main() -> horizon_client::Result<()> {
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
let names = horizon_client::creative::Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut out = String::new();
for (line, order) in cases.lines().zip(orders.lines()) {
@@ -0,0 +1,132 @@
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
//! the game's `RandomTickSections` choosing the sections of a chunk that
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
//! the prepared resources). The level the ticks run against reads as air
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
random_tick_sections::RandomTickSections,
};
use horizon_core::{
position::{BlockPos, ChunkPos},
random::LegacyRandom,
};
use horizon_world::{
fluid::FluidKind,
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
registry::BlockStateId,
};
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
struct Air {
reads: RefCell<Vec<BlockPos>>,
random: LegacyRandom,
speed: i32,
}
impl LevelAccess for Air {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.reads.borrow_mut().push(pos);
BlockStateId(0)
}
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
None
}
fn is_loaded(&self, _: BlockPos) -> bool {
true
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
0
}
fn game_rules(&self) -> GameRules {
GameRules {
random_tick_speed: self.speed,
..Default::default()
}
}
fn emit(&mut self, _: LevelEvent) {}
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let root = input
.lines()
.find_map(|l| l.strip_prefix("#resources "))
.map(PathBuf::from)
.ok_or("no #resources line")?;
let registry = data::load_registry(&root)?;
let (facts, behaviours) = BlockFacts::load_pair(&root, &registry)?;
let mut output = String::new();
for line in input.lines().filter(|l| !l.starts_with('#')) {
let mut words = line.split(' ');
let mut ticks = Ticks::new(0);
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
let mut level = Air {
reads: RefCell::default(),
random: LegacyRandom::new(0),
speed: words.next().ok_or("no speed")?.parse()?,
};
let mut chunk = Arc::new(Chunk::empty(0, 0));
let mut cache = RandomTickSections::default();
for word in words {
let op: Vec<&str> = word.split(':').collect();
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
match op[0] {
"s" => {
let (section, index) = (int(1)?, int(2)?);
Arc::make_mut(&mut chunk).set(
index & 15,
MIN_Y + section * 16 + (index >> 8),
index >> 4 & 15,
int(3)? as u32,
);
}
"f" => {
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
.blocks = Section::from_blocks(&values);
}
"t" => {
output.push('|');
level.reads.borrow_mut().clear();
behaviours.tick_chunk_random(
&mut level,
&mut ticks,
ChunkPos::new(0, 0),
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
);
for pos in level.reads.borrow().iter() {
let state = chunk.block(pos.x, pos.y, pos.z);
let fluid = matches!(
behaviours.fluid(state).kind(),
FluidKind::Lava | FluidKind::FlowingLava
);
write!(
output,
" {},{},{},{}{}",
pos.x,
pos.y,
pos.z,
if behaviours.is_randomly_ticking(state) {
'B'
} else {
'-'
},
if fluid { 'F' } else { '-' },
)?;
}
}
other => return Err(format!("unknown op {other}").into()),
}
}
writeln!(output, " | {}", ticks.random_tick_value)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -78,6 +78,7 @@ fn main() -> Result<()> {
let mut selected = collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
};
let movement = world_movement::resolve_world_movement(
&mut selected,
@@ -3,6 +3,7 @@
//! what the render thread pays for it in `Streaming::update`.
//!
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
//! [--dimension overworld|the_nether|the_end] [--center-x X] [--center-z Z]
//!
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
//! at that speed for T seconds while frames run at F per second. Each phase reports
@@ -72,14 +73,33 @@ static ALLOCATOR: CountingAllocator = CountingAllocator;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn cpu_seconds() -> f64 {
std::fs::read_to_string("/proc/self/stat")
.ok()
.and_then(|stat| {
let fields = stat
.rsplit_once(')')?
.1
.split_whitespace()
.collect::<Vec<_>>();
Some((fields[11].parse::<f64>().ok()? + fields[12].parse::<f64>().ok()?) / 100.0)
})
.unwrap_or(0.0)
}
#[derive(Default)]
struct Phase {
updates: Vec<f64>,
local: (u64, u64),
uploads: usize,
stage_samples: [u64; 7],
cpu_start: f64,
}
impl Phase {
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
if self.updates.is_empty() {
self.cpu_start = cpu_seconds();
}
let before = LOCAL.with(Cell::get);
let started = Instant::now();
let (uploads, _) = streaming.update(world);
@@ -89,6 +109,9 @@ impl Phase {
self.local.1 += after.1 - before.1;
self.uploads += uploads.len();
drop(uploads);
for (sum, (_, count)) in self.stage_samples.iter_mut().zip(streaming.stages()) {
*sum += count as u64;
}
}
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
let mut sorted = self.updates.clone();
@@ -106,6 +129,16 @@ impl Phase {
at(0.99),
sorted.last().copied().unwrap_or(0.0),
);
println!(
"BENCH {}",
serde_json::json!({
"phase": name, "wall_s": wall.as_secs_f64(),
"cpu_s": cpu_seconds() - self.cpu_start,
"chunks": streaming.generated_total - generated, "sections": self.uploads,
"updates": self.updates.len(), "update_ms": total,
"mean_stages": self.stage_samples.map(|n| n as f64 / self.updates.len() as f64),
})
);
if TRACK {
println!(
" update allocations: {} ({:.1} MiB)",
@@ -142,6 +175,22 @@ fn main() -> Result<()> {
let fly = flag("--fly", 0.0);
let seconds = flag("--seconds", 10.0);
let hz = flag("--hz", 240.0);
let dimension = args
.iter()
.position(|a| a == "--dimension")
.and_then(|i| args.get(i + 1))
.map(|d| {
if d.contains(':') {
d.clone()
} else {
format!("minecraft:{d}")
}
})
.unwrap_or_else(|| "minecraft:overworld".into());
let center = (
flag("--center-x", 0.0) as i32,
flag("--center-z", 0.0) as i32,
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
@@ -154,11 +203,12 @@ fn main() -> Result<()> {
models::load_visibility(&root, assets.registry)?,
)?);
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
let terrain = Arc::new(VanillaTerrain::new(
let terrain = Arc::new(VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
&dimension,
)?);
let context = Arc::new(WorkerContext {
assets,
@@ -170,8 +220,12 @@ fn main() -> Result<()> {
let mut world = World::new(facts.air, seed);
let mut streaming = Streaming::new(terrain, context, threads);
streaming.radius = radius;
streaming.center = (0, 0);
println!("{threads} workers, radius {radius}, seed {seed}");
streaming.center = center;
streaming.nether_shading = dimension == "minecraft:the_nether";
streaming.sky_light = dimension != "minecraft:the_nether";
println!(
"{threads} workers, radius {radius}, seed {seed}, dimension {dimension}, center {center:?}"
);
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
// Fill: update as often as the game would uncapped, until settled.
@@ -198,12 +252,12 @@ fn main() -> Result<()> {
let mut behind = Vec::new();
while started.elapsed().as_secs_f64() < seconds {
let next = Instant::now() + frame;
let x = (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, 0);
let x = center.0 + (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, center.1);
phase.update(&mut streaming, &mut world);
// Columns within 2 chunks of the player still missing their meshes.
let near = (-2..=2)
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, center.1 + dz)))
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
.count();
behind.push(near);
@@ -239,10 +239,12 @@ fn main() -> horizon_client::Result<()> {
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
let trades = Trades::load(root);
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::default();
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
.types
.into_iter()
@@ -378,7 +380,11 @@ fn main() -> horizon_client::Result<()> {
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: Some(&env_trade),
maps: None,
};
trade(&mut out, name, &kv, &mut world, &env, origin)?;
}
@@ -11,6 +11,7 @@ fn bits(value: f32) -> String {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let mut output = String::new();
let mut parameters = weather::ParameterCache::default();
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
let p: Vec<&str> = line.split_whitespace().collect();
let int = |i: usize| p[i].parse::<i32>().unwrap();
@@ -38,6 +39,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
bits(column.v_offset),
column.light_coords
)?;
assert_eq!(
parameters
.get(x, z)
.column(kind != 0, ticks, x, bottom, top, z, light, partial),
column,
"cached parameters differ from original random source at case {case}"
);
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
write!(
@@ -22,6 +22,11 @@ impl PlayerMotion {
pub fn refresh_attributes(&mut self) -> Result<()> {
live_updates::refresh(self)
}
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
/// (the saved `AbsorptionAmount`, or what a client's server sent).
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
self.store_absorption(value)
}
}
impl HealthWorld for PlayerMotion {
type Error = Box<dyn std::error::Error>;
+1 -7
View File
@@ -2,7 +2,7 @@
//! `banner_patterns` layers carried by banner items, shields and placed
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
})
.collect()
}
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
let data: &mut item_data::StackData = stack.data_mut();
data.banner_patterns = layers;
stack.tidy();
}
#[cfg(test)]
mod tests {
+19
View File
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
driver: text(&renderer["driver"]),
backend: text(&renderer["backend"]).to_lowercase(),
kind: text(&renderer["device_type"]),
vram_mb: None,
});
if let Some(gpu) = report.hardware.gpu.as_mut() {
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
let vendor = if gpu.vendor.is_empty() {
vendor
} else {
gpu.vendor.as_str()
}
.to_owned();
if gpu.kind.contains("iscrete") {
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
}
}
report.hardware.storage =
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let status = control.status();
let renderer = &status["renderer"];
// On Wayland the window is on a screen only once it has been shown: ask again.
if report.hardware.display.is_none() {
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
}
let c = &mut report.conditions;
c.scenario = Some(setup.scenario.clone());
c.seed = Some(SEED.to_string());
@@ -32,16 +32,18 @@ pub enum Group {
Move,
Act,
Screens,
Multiplayer,
World,
Scripting,
}
impl Group {
pub const ALL: [Group; 7] = [
pub const ALL: [Group; 8] = [
Group::Session,
Group::Observe,
Group::Move,
Group::Act,
Group::Screens,
Group::Multiplayer,
Group::World,
Group::Scripting,
];
@@ -52,6 +54,7 @@ impl Group {
Group::Move => "Move and look",
Group::Act => "Act on the world",
Group::Screens => "Screens, menus and chat",
Group::Multiplayer => "Multiplayer, LAN and friends",
Group::World => "World and cheats",
Group::Scripting => "Scripting and benchmarks",
}
@@ -117,6 +120,50 @@ const OVER: Param = flag(
"turn with mouse motion spread over this many seconds (eased), as a player would",
);
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
const BOX: [Param; 6] = [
p("x1", Ty::Int, "corner X"),
p("y1", Ty::Int, "corner Y"),
p("z1", Ty::Int, "corner Z"),
p("x2", Ty::Int, "opposite X"),
p("y2", Ty::Int, "opposite Y"),
p("z2", Ty::Int, "opposite Z"),
];
const PAGES: Ty = Ty::Enum(&[
"main",
"video",
"sound",
"controls",
"keybinds",
"controller",
"controller-binds",
"mouse",
"chat",
"accessibility",
"language",
"font",
"packs",
"mods",
"graphics",
"graphics-advanced",
"extras",
"horizon",
"shader-packs",
"agent",
]);
/// `--confirm`, required by destructive commands: the player agreed.
const CONFIRM: Param = Param {
name: "confirm",
ty: Ty::Bool,
required: true,
flag: true,
help: "true once the player agreed: it cannot be undone",
};
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
macro_rules! cmd {
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
mcp: true, local: true),
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
"", ["horizon-ctl status"], &[], mcp: false, local: false),
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
["horizon-ctl world-delete Test --confirm"],
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
["horizon-ctl light 0 64 0 15 70 15"],
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
&BOX, mcp: true, local: false),
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
&BOX, mcp: true, local: false),
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
// Screens
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
// Multiplayer
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
// World
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
@@ -366,8 +464,9 @@ pub fn overview() -> String {
}
out.push_str(
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
Actions (goto, break, place...) block until done.\n\
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
out
}
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
pub fn markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|");
let mut out = String::from(
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
# horizon-ctl commands\n\n\
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
arguments. Positional arguments come first, in order; `--flags` after them. \
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
);
for group in Group::ALL {
out.push_str(&format!(
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
group.title()
));
for c in COMMANDS.iter().filter(|c| c.group == group) {
let tool = if c.mcp {
let mut t = format!("`{}`", tool_name(c));
if DESTRUCTIVE.contains(&c.name) {
t.push_str(" (destructive)");
} else if OPEN_WORLD.contains(&c.name) {
t.push_str(" (network)");
}
t
} else {
"CLI only".to_owned()
};
let args: Vec<String> = c
.params
.iter()
.map(|p| {
let ty = match p.ty {
Ty::Int => "integer".to_owned(),
Ty::Num => "number".to_owned(),
Ty::Str => "string".to_owned(),
Ty::Rest => "text".to_owned(),
Ty::Bool => "boolean".to_owned(),
Ty::Enum(v) => v.join(", "),
};
let name = if p.flag {
format!("--{}", p.name)
} else {
p.name.to_owned()
};
let need = if p.required { ", required" } else { "" };
format!("`{name}` ({ty}{need}): {}", cell(p.help))
})
.collect();
out.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
cell(c.usage),
tool,
cell(&capitalize(c.summary)),
if args.is_empty() {
"none".to_owned()
} else {
args.join("<br>")
}
));
}
}
out
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
chars
@@ -640,6 +806,43 @@ mod tests {
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
}
#[test]
fn the_generated_reference_is_up_to_date() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/horizon-ctl-commands.md"
);
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
assert!(
on_disk == markdown(),
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
);
for c in COMMANDS {
assert!(
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
"{} undocumented",
c.name
);
}
}
#[test]
fn destructive_and_network_tools_exist() {
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
}
let delete = find("world-delete").unwrap();
assert_eq!(
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
["world-delete", "A", "--confirm"]
);
let lan = find("lan").unwrap();
assert_eq!(
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
["lan", "open", "commands"]
);
}
#[test]
fn help_mentions_every_command() {
let text = overview();
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
const GAME_ERROR: u8 = 1;
const USAGE: u8 = 2;
const UNREACHABLE: u8 = 3;
/// No AI is on (`horizon_core::no_ai`): agent control is off.
const DENIED: u8 = 4;
/// A failure, with the exit status it maps to.
#[derive(Debug)]
@@ -34,6 +36,13 @@ impl Failure {
status: USAGE,
}
}
/// No AI is on in the game's home.
fn denied() -> Self {
Self {
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
status: DENIED,
}
}
fn unreachable(message: impl Into<String>) -> Self {
Self {
reply: json!({ "error": message.into(), "kind": "connection" }),
@@ -98,6 +107,8 @@ impl Client {
pub struct Session {
pub socket: PathBuf,
pub timeout: Option<Duration>,
/// The game's home, where No AI is read (`horizon_core::no_ai`).
pub home: PathBuf,
client: Option<Client>,
}
@@ -106,9 +117,14 @@ impl Session {
Self {
socket,
timeout,
home: horizon_client::logging::game_home(),
client: None,
}
}
/// A failure while No AI is on: every command refuses (help aside).
pub fn allowed(&self) -> Result<(), Failure> {
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
}
fn client(&mut self) -> Result<&mut Client, Failure> {
if self.client.is_none() {
self.client = Some(Client::connect(&self.socket, self.timeout)?);
@@ -137,6 +153,7 @@ impl Session {
let Some(name) = words.first().map(String::as_str) else {
return Err(Failure::usage("no command; try horizon-ctl help"));
};
self.allowed()?;
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
return Err(Failure::usage(
"use horizon-ctl COMMAND --help outside scripts",
@@ -174,6 +191,18 @@ impl Session {
ready["launch"] = reply;
Ok(ready)
}
"interact-entity" => {
let id = match words {
[_, id] if id.parse::<i64>().is_ok() => id,
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
};
self.game(&format!("look-at entity {id}"))?;
self.game("mouse right click")?;
self.game("wait 2")?;
let mut screen = self.game("screen")?;
screen["ok"] = json!(true);
Ok(screen)
}
"hold" => {
let (key, seconds) = match words {
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
@@ -183,7 +212,7 @@ impl Session {
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
self.game(&format!("key {key} up"))
}
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
"logs" => logs(&self.home, &words[1..]),
"wait-ready" => {
let limit = if words.len() > 1 {
number(1, "SECONDS")?
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
print!("{}", commands::overview());
0
}
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
print!("{}", commands::markdown());
0
}
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
None => {
print!("{}", commands::overview());
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
}
}
}
Some("mcp") if session.allowed().is_err() => {
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
DENIED
}
Some("mcp") => match mcp::serve(&mut session) {
Ok(()) => 0,
Err(e) => {
@@ -581,6 +618,27 @@ mod tests {
assert_eq!(f.status, USAGE);
}
#[test]
fn no_ai_refuses_every_command() {
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
s.home = home.clone();
assert_eq!(
s.run(&["state".to_owned()]).unwrap_err().status,
UNREACHABLE
);
horizon_core::no_ai::set(&home, true).unwrap();
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
let f = s.run(&words).unwrap_err();
assert_eq!(f.status, DENIED, "{words:?}");
assert_eq!(f.reply["kind"], "denied");
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn screenshot_paths_become_absolute() {
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
schema = json!({ "type": "object", "properties": {} });
description = "Capture the game window and return it as a PNG image.".into();
}
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
if commands::DESTRUCTIVE.contains(&c.name) {
tool["annotations"] = json!({ "destructiveHint": true });
} else if commands::OPEN_WORLD.contains(&c.name) {
tool["annotations"] = json!({ "openWorldHint": true });
}
tool
})
.collect()
}
@@ -144,9 +144,6 @@ impl BlockEntities {
pub fn set_states(&mut self, states: Vec<Ticking>) {
self.states = states;
}
pub fn take_states(&mut self) -> Vec<Ticking> {
std::mem::take(&mut self.states)
}
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
@@ -382,10 +382,6 @@ impl Shulker {
}
out
}
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
pub fn get_progress(&self, a: f32) -> f32 {
self.progress_old + a * (self.progress - self.progress_old)
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
@@ -668,14 +664,6 @@ impl Skull {
self.animating = false;
}
}
/// `getAnimation(a)`.
pub fn get_animation(&self, a: f32) -> f32 {
if self.animating {
self.tick_count as f32 + a
} else {
self.tick_count as f32
}
}
}
/// `DecoratedPotBlockEntity.WobbleStyle`.
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
visibility: None,
}
}
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
Some((
self.models.get("standing_banner")?,
self.models.get("standing_banner_flag")?,
))
}
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
+1 -1
View File
@@ -46,7 +46,7 @@ impl BossBar {
style: "progress".into(),
value: 0,
max: 100,
visible: false,
visible: true,
player: false,
}
}
-4
View File
@@ -169,10 +169,6 @@ impl TickRateManager {
self.frozen_ticks_to_run -= 1;
}
}
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
!self.runs_normally() && !is_player_or_carries_player
}
}
/// What `/tick sprint` reports when it ends.
+13 -2
View File
@@ -3,7 +3,7 @@
//! Entity-dependent rules use the defaults for players and the supported mobs.
use crate::{Result, world::World};
use horizon_core::{
geometry::{Aabb, Vec3, VoxelShape},
geometry::{Aabb, Vec3, VoxelShape, border::BorderGeometry},
position::{BlockPos, ChunkPos},
};
use horizon_world::{
@@ -64,6 +64,9 @@ pub struct SceneCollisions<'w, 'r> {
pub struct EntityCollisions<'a, W> {
pub blocks: W,
pub entities: &'a [Aabb],
/// The world border and the moving entity's x and z
/// (`CollisionGetter.borderCollision`); None: the blocks' answer.
pub border: Option<(&'a BorderGeometry, [f64; 2])>,
}
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
type Error = W::Error;
@@ -114,12 +117,20 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
}
Ok(shapes)
}
/// `WorldBorder.isInsideCloseToBorder`: an entity inside the border and
/// near it collides with its walls.
fn movement_border(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Option<VoxelShape>> {
self.blocks.movement_border(entity, bounds)
let Some((border, [x, z])) = self.border else {
return self.blocks.movement_border(entity, bounds);
};
if !border.is_inside_close_to_border(x, z, bounds) {
return Ok(None);
}
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl SceneCollisions<'_, '_> {
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
}
s.parse().ok()
}
/// Long.parseLong.
pub fn java_parse_long(s: &str) -> Option<i64> {
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Double.parseDouble over the characters StringReader reads as a number.
pub fn java_parse_double(s: &str) -> Option<f64> {
let t = s.trim_matches(|c: char| c <= ' ');
@@ -1,7 +1,7 @@
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
//! SNBT text components, with the data-fixer-upper error messages
//! (ComponentArgument, StyleArgument and component-valued item data).
use super::codec::{get, no_key, not_list, not_map, snbt};
use super::codec::{get, no_key, not_list, not_map};
use horizon_world::nbt::{Compound, Tag};
/// A DataResult error: its message and whether it carries a partial result.
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
_ => Err(not_map(t)),
}
}
#[allow(unused)]
fn _keep(t: &Tag) -> String {
snbt(t)
}
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
}
#[allow(unused)]
fn _unused(t: &Tag) -> String {
snbt(t)
}
#[cfg(test)]
mod tests {
-30
View File
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
pub fn read_int(&mut self) -> CmdResult<i32> {
self.number("int")
}
pub fn read_long(&mut self) -> CmdResult<i64> {
self.number("long")
}
pub fn read_double(&mut self) -> CmdResult<f64> {
self.number("double")
}
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
}
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
}
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
if !self.can_read() {
return Ok(String::new());
}
let next = self.peek();
if next != '"' && next != '\'' {
return Err(CommandError::tr_at(
"parsing.quote.expected.start",
&[],
self,
));
}
self.skip();
self.read_string_until(next)
}
pub fn read_string(&mut self) -> CmdResult<String> {
if !self.can_read() {
return Ok(String::new());
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
}
}
}
/// The same with a tooltip per candidate.
pub fn suggest_matching_tooltip<'b>(
&mut self,
candidates: impl IntoIterator<Item = (&'b str, String)>,
) {
let lower = self.remaining_lower();
for (c, tip) in candidates {
if args::matches_sub_str(&lower, &c.to_lowercase()) {
self.suggest_tooltip(c, tip);
}
}
}
}
/// String.compareToIgnoreCase.
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
+1 -5
View File
@@ -1,8 +1,6 @@
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
//! error messages and completion suggestions, producing NBT tags.
use super::packrat::{
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
};
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
use horizon_world::nbt::{Compound, JavaString, Tag};
use std::rc::Rc;
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
}
}
}
#[allow(unused)]
fn _rule_kind(_: Rule<SV>) {}
+1 -7
View File
@@ -3,7 +3,7 @@
//! mode keywords are read back from the parsed literals.
use super::{
Dispatcher,
args::{ArgType, GameMode, Resource, integer},
args::{ArgType, Resource, integer},
};
use crate::gamerules::{RULES, Value};
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
d.executes(l, "clone");
}
}
/// Game mode names for selector `gamemode=` and the argument.
pub fn game_modes() -> [&'static str; 4] {
GameMode::ALL.map(GameMode::name)
}
/// The shared vanilla tree.
/// Reorder node children as vanilla registers them (Brigadier keeps
/// children in registration order; smart usage lists them that way).
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
TREE.get_or_init(|| build_with(false))
}
#[allow(unused)]
const _: (u8, u8) = (ALL, GAMEMASTERS);
+23
View File
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.font = o.get("font").and_then(crate::text::font_id);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -266,4 +267,26 @@ mod tests {
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
.unwrap();
let t = to_text(&v, &mut |_| Text::empty());
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
let (alt, uniform) = (
Some("minecraft:alt".into()),
Some("minecraft:uniform".into()),
);
assert_eq!(fonts, [alt.clone(), alt, uniform]);
// Back to JSON with the field, which a re-read keeps.
let again = to_text(&t.component(), &mut |_| Text::empty());
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
assert_eq!(kept, fonts);
// Not an identifier: no font.
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
}
+1 -19
View File
@@ -252,24 +252,6 @@ impl BlockEntity {
};
Some(Self::Chest(vec![None; size]))
}
/// The vanilla block entity type id of a container block.
pub fn id_for_block(name: &str) -> Option<&'static str> {
Some(match name {
"minecraft:chest" => "minecraft:chest",
n if n.ends_with("copper_chest") => "minecraft:chest",
"minecraft:trapped_chest" => "minecraft:trapped_chest",
"minecraft:barrel" => "minecraft:barrel",
"minecraft:hopper" => "minecraft:hopper",
"minecraft:dispenser" => "minecraft:dispenser",
"minecraft:dropper" => "minecraft:dropper",
"minecraft:furnace" => "minecraft:furnace",
"minecraft:blast_furnace" => "minecraft:blast_furnace",
"minecraft:smoker" => "minecraft:smoker",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:crafter" => "minecraft:crafter",
_ => return None,
})
}
pub fn id(&self) -> &'static str {
match self {
Self::Chest(items) => match items.len() {
@@ -528,7 +510,7 @@ impl Furnace {
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient).cloned() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
+52 -2
View File
@@ -13,7 +13,11 @@ use std::{
io::{self, BufRead, BufReader, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
path::{Path, PathBuf},
sync::mpsc,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
mpsc,
},
thread,
};
@@ -151,6 +155,12 @@ pub struct Server {
path: PathBuf,
requests: mpsc::Receiver<Request>,
sender: mpsc::Sender<Request>,
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
clients: Arc<AtomicUsize>,
/// Set when the server is dropped: the listener stops accepting and exits.
closed: Arc<AtomicBool>,
/// The TCP port, to wake the listener when closing.
port: Option<u16>,
}
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
@@ -174,10 +184,15 @@ impl Server {
}
let (sender, requests) = mpsc::channel();
let local = sender.clone();
let clients = Arc::new(AtomicUsize::new(0));
let counted = clients.clone();
let closed = Arc::new(AtomicBool::new(false));
let mut tcp_port = None;
if uses_tcp(path) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let token = random_token();
let port = listener.local_addr()?.port();
tcp_port = Some(port);
write_private(
path,
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
@@ -186,6 +201,8 @@ impl Server {
spawn_listener(
move || listener.accept().map(|(s, _)| s),
sender,
counted,
closed.clone(),
move |stream: TcpStream| {
let _ = stream.set_nodelay(true);
let mut stream = Stream::Tcp(stream);
@@ -199,6 +216,8 @@ impl Server {
spawn_listener(
move || listener.accept().map(|(s, _)| s),
sender,
counted,
closed.clone(),
|s| Some(Stream::Unix(s)),
)?;
}
@@ -207,6 +226,9 @@ impl Server {
path: path.to_owned(),
requests,
sender: local,
clients,
closed,
port: tcp_port,
})
}
/// Commands from inside the process only (`--benchmark` without `--control`).
@@ -216,8 +238,15 @@ impl Server {
path: PathBuf::new(),
requests,
sender,
clients: Arc::default(),
closed: Arc::default(),
port: None,
}
}
/// How many clients are connected.
pub fn clients(&self) -> usize {
self.clients.load(Ordering::Relaxed)
}
/// Sends commands as a socket client would, from another thread.
pub fn sender(&self) -> mpsc::Sender<Request> {
self.sender.clone()
@@ -234,6 +263,8 @@ impl Server {
fn spawn_listener<S: Send + 'static>(
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
sender: mpsc::Sender<Request>,
clients: Arc<AtomicUsize>,
closed: Arc<AtomicBool>,
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
) -> io::Result<()> {
let accept = std::sync::Arc::new(accept);
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
.name("horizon-control".into())
.spawn(move || {
loop {
let Ok(stream) = accept_next() else {
let next = accept_next();
if closed.load(Ordering::Relaxed) {
return;
}
let Ok(stream) = next else {
thread::sleep(std::time::Duration::from_millis(10));
continue;
};
let sender = sender.clone();
let accept = accept.clone();
let clients = clients.clone();
let _ = thread::Builder::new()
.name("horizon-control-client".into())
.spawn(move || {
if let Some(stream) = accept(stream) {
clients.fetch_add(1, Ordering::Relaxed);
serve(stream, &sender);
clients.fetch_sub(1, Ordering::Relaxed);
}
});
}
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
impl Drop for Server {
fn drop(&mut self) {
if self.path.as_os_str().is_empty() {
return;
}
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
self.closed.store(true, Ordering::Relaxed);
match self.port {
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
#[cfg(unix)]
None => drop(UnixStream::connect(&self.path)),
#[cfg(not(unix))]
None => {}
}
let _ = std::fs::remove_file(&self.path);
}
}
+96
View File
@@ -1102,7 +1102,11 @@ pub struct Recipes {
facts: HashMap<Arc<str>, Facts>,
/// The `#minecraft:curse` enchantments.
curses: HashSet<String>,
/// The first cooking recipe of each kind taking each item, built at the
/// first lookup (furnaces look theirs up every tick).
cook_index: std::sync::OnceLock<CookIndex>,
}
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, usize>>;
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
match v {
@@ -1421,6 +1425,7 @@ impl Recipes {
meta,
facts,
curses,
cook_index: Default::default(),
})
}
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
@@ -1606,6 +1611,22 @@ impl Recipes {
out
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
for (i, c) in self.cooking.iter().enumerate() {
let of_kind = index.entry(c.kind.clone()).or_default();
for item in &c.ingredient.0 {
of_kind.entry(item.clone()).or_insert(i);
}
}
index
});
let i = *index.get(kind)?.get(&*input.item)?;
self.cooking.get(i)
}
/// `cook` by scanning every recipe, as it was before the index.
#[cfg(test)]
fn cook_scan(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
self.cooking
.iter()
.find(|c| c.kind == kind && c.ingredient.test(input))
@@ -1625,6 +1646,80 @@ mod tests {
s.data_mut().banner_patterns = vec![BannerLayer::new("minecraft:cross", "red")];
s
}
/// About vanilla's count of cooking recipes: four kinds, ingredients of
/// one to twelve items, overlapping (several recipes take some items).
fn cooking_book() -> Recipes {
let kinds = ["smelting", "blasting", "smoking", "campfire_cooking"];
let cooking = (0..320usize)
.map(|i| Cooking {
id: format!("r{i}"),
category: "misc".into(),
group: String::new(),
kind: kinds[i % 4].into(),
ingredient: ingredient(
&(0..1 + i % 12)
.map(|k| format!("minecraft:item_{}", (i * 7 + k * 13) % 400))
.collect::<Vec<_>>()
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
),
result: ItemStack::new("minecraft:stone", 1),
experience: 0.1,
time: 200,
})
.collect();
Recipes {
crafting: Vec::new(),
cooking,
meta: HashMap::new(),
facts: HashMap::new(),
curses: HashSet::new(),
cook_index: Default::default(),
}
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
for kind in [
"smelting",
"blasting",
"smoking",
"campfire_cooking",
"other",
] {
for n in 0..420 {
let stack = ItemStack::new(&format!("minecraft:item_{n}"), 1);
let a = recipes.cook(kind, &stack).map(|c| &c.id);
let b = recipes.cook_scan(kind, &stack).map(|c| &c.id);
assert_eq!(a, b, "{kind} item_{n}");
}
}
}
/// A furnace's recipe lookup, once per lit furnace per tick.
#[test]
#[ignore]
fn cook_cost() {
let recipes = cooking_book();
let stacks: Vec<ItemStack> = (0..1000)
.map(|n| ItemStack::new(&format!("minecraft:item_{}", n % 400), 1))
.collect();
let t = std::time::Instant::now();
let a = stacks
.iter()
.filter(|s| recipes.cook_scan("smelting", s).cloned().is_some())
.count();
let before = t.elapsed().as_secs_f64() * 1e3;
recipes.cook("smelting", &stacks[0]);
let t = std::time::Instant::now();
let b = stacks
.iter()
.filter(|s| recipes.cook("smelting", s).is_some())
.count();
let after = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(a, b);
println!("1000 furnace lookups (scan and clone -> index): {before:.3} ms -> {after:.3} ms");
}
fn run(special: &Special, cells: &[ItemStack]) -> Option<ItemStack> {
let recipes = Recipes {
crafting: Vec::new(),
@@ -1632,6 +1727,7 @@ mod tests {
meta: HashMap::new(),
facts: HashMap::new(),
curses: HashSet::new(),
cook_index: Default::default(),
};
let ctx = Context {
recipes: &recipes,
+1
View File
@@ -44,6 +44,7 @@ pub struct Creative {
search_index: Vec<SearchEntry>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
pub struct Names {
map: HashMap<String, String>,
}
-6
View File
@@ -17,8 +17,6 @@ use std::{
pub const CAPACITY: usize = 240;
/// `AbstractDebugChart.CHART_HEIGHT`.
pub const CHART_HEIGHT: i32 = 60;
/// `TpsDebugDimensions`: a tick sample's values.
pub const FULL_TICK: usize = 0;
pub const TICK_SERVER_METHOD: usize = 1;
pub const SCHEDULED_TASKS: usize = 2;
pub const IDLE: usize = 3;
@@ -252,10 +250,6 @@ impl Chart {
pub fn width(max_width: i32) -> i32 {
(CAPACITY as i32 + 2).min(max_width)
}
/// `getFullHeight`: the box and the min/avg/max line above it.
pub fn full_height() -> i32 {
CHART_HEIGHT + 9
}
/// `AbstractDebugChart.extractRenderState`.
pub fn draw(
self,
+3 -17
View File
@@ -60,6 +60,7 @@ pub struct ItemProps {
/// `minecraft:brewing_fuel` present.
pub brewing_fuel: bool,
}
#[derive(Default)]
pub struct Enchantments {
pub list: Vec<Enchantment>,
index: HashMap<Arc<str>, usize>,
@@ -192,6 +193,8 @@ impl Enchantments {
|| k.starts_with("item.minecraft.splash_potion")
|| k.starts_with("item.minecraft.lingering_potion")
|| k.starts_with("item.minecraft.tipped_arrow")
|| k.starts_with("item.minecraft.firework_rocket.")
|| k.starts_with("item.minecraft.firework_star.")
|| k.starts_with("trim_")
|| k.starts_with("potion.")
|| k.starts_with("container.")
@@ -356,10 +359,6 @@ impl Enchantments {
}
out
}
/// Whether a stack draws the enchantment glint.
pub fn has_glint(&self, stack: &ItemStack) -> bool {
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
}
// ---- Enchanting table (EnchantmentHelper) ----
@@ -511,19 +510,6 @@ impl Enchantments {
.is_some_and(|e| e.effects.get(component).is_some())
})
}
/// The highest level among enchantments on `stack` that have `component`.
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
stack
.enchantments()
.iter()
.filter(|(id, _)| {
self.get(id)
.is_some_and(|e| e.effects.get(component).is_some())
})
.map(|(_, l)| *l)
.max()
.unwrap_or(0)
}
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
let mut out = Vec::new();
+79 -5
View File
@@ -98,12 +98,73 @@ impl ItemEntity {
lava_hurt,
}
}
/// Returns false when the item should be removed.
/// `ItemEntity.tick` on a server: an item whose box is inside a block
/// (one placed where it lay) has no physics this tick
/// (`level.noCollision(box.deflate(1.0E-7))`).
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!crate::collisions::no_collision_empty(&mut scene, inner)
}
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
/// towards the nearest side of its block (north, south, west, east or
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
/// 0.1`).
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
let bounds = self.bounds();
let at = Vec3::new(
self.position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
self.position.z,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = self.velocity.scale(0.75);
self.velocity = match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
};
}
/// Returns false when the item should be removed. `no_physics`: the item
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
pub fn tick(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -140,15 +201,22 @@ impl ItemEntity {
subject: None,
};
let wanted = self.velocity;
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO);
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
if !no_physics {
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
}
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
@@ -583,6 +651,10 @@ pub struct ArmorStand {
pub no_base_plate: bool,
pub invisible: bool,
pub marker: bool,
/// `CustomName`: the text of a hologram.
pub custom_name: Option<crate::text::Text>,
/// `CustomNameVisible`.
pub name_visible: bool,
/// Game time of the last hit (the wobble and the second-hit window).
pub last_hit: i64,
}
@@ -603,6 +675,8 @@ impl ArmorStand {
no_base_plate: false,
invisible: false,
marker: false,
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
}
}
+140
View File
@@ -43,6 +43,7 @@ struct Out {
@location(8) back_color: vec4<f32>,
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -109,6 +110,13 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(6) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
colors: array<vec4<f32>, 4>,
@@ -209,3 +217,135 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
return vec4(0.0);
}
// WEATHER_MATH_BEGIN
// Unsigned 64-bit helpers (low word first). Snow adds a random double to a
// float drift before casting to float. Retaining the 53-bit base avoids early
// rounding, including ties, without requiring optional GPU float64 support.
fn weather_shr_jam(v: vec2<u32>, n: u32) -> vec2<u32> {
if n == 0u { return v; }
if n < 32u {
return vec2((v.x >> n) | (v.y << (32u - n)) | select(0u, 1u, (v.x << (32u - n)) != 0u), v.y >> n);
}
if n == 32u { return vec2(v.y | select(0u, 1u, v.x != 0u), 0u); }
if n < 64u {
return vec2((v.y >> (n - 32u)) | select(0u, 1u, v.x != 0u || (v.y << (64u - n)) != 0u), 0u);
}
return vec2(select(0u, 1u, any(v != vec2(0u))), 0u);
}
fn weather_add64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
let lo = a.x + b.x;
return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x));
}
fn weather_sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
}
fn weather_double_plus_float(base: vec2<u32>, delta: f32) -> f32 {
// The random base is nonnegative and either zero or >= 2^-53.
if all(base == vec2(0u)) { return select(delta, 0.0, delta == 0.0); }
let db = bitcast<u32>(delta);
let mag = db & 0x7fffffffu;
let be = i32((base.y >> 20u) & 2047u) - 1023;
var de = i32(mag >> 23u) - 127;
var dm = (mag & 0x7fffffu) | 0x800000u;
if mag < 0x800000u {
dm = mag;
if dm == 0u { de = -200; }
else {
let shift = countLeadingZeros(dm) - 8u;
dm <<= shift;
de = -126 - i32(shift);
}
}
let e = max(be, de);
// Leading significand bit at 61, with room for addition and guard bits.
var a = vec2(base.x << 9u, ((base.y & 0xfffffu) | 0x100000u) << 9u | (base.x >> 23u));
var b = vec2(0u, dm << 6u);
a = weather_shr_jam(a, u32(e - be));
b = weather_shr_jam(b, u32(e - de));
var sign = 0u;
var value: vec2<u32>;
if (db >> 31u) == 0u { value = weather_add64(a, b); }
else if a.y > b.y || (a.y == b.y && a.x >= b.x) { value = weather_sub64(a, b); }
else { value = weather_sub64(b, a); sign = 0x80000000u; }
if all(value == vec2(0u)) { return 0.0; }
let leading = select(31u - countLeadingZeros(value.x), 63u - countLeadingZeros(value.y), value.y != 0u);
var exponent = e + i32(leading) - 61;
// Normalize the leading bit to 62. After jamming, bit 38 is the guard
// bit and bits 0..37 distinguish a tie from a value above the midpoint.
let shift = 62u - leading;
if shift >= 32u { value = vec2(0u, value.x << (shift - 32u)); }
else if shift != 0u { value = vec2(value.x << shift, (value.y << shift) | (value.x >> (32u - shift))); }
// Vanilla first rounds the addition to double, then casts to float.
// Round at bit 10 (53 retained bits) before the 24-bit rounding below;
// otherwise a double-rounding midpoint can select the wrong float.
let double_guard = (value.x & 512u) != 0u;
let double_sticky = (value.x & 511u) != 0u;
let double_odd = (value.x & 1024u) != 0u;
value.x &= 0xfffffc00u;
if double_guard && (double_sticky || double_odd) {
value = weather_add64(value, vec2(1024u, 0u));
}
if (value.y & 0x80000000u) != 0u {
value = weather_shr_jam(value, 1u);
exponent += 1;
}
var mantissa = value.y >> 7u;
let guard = (value.y & 64u) != 0u;
let sticky = (value.y & 63u) != 0u || value.x != 0u;
if guard && (sticky || (mantissa & 1u) != 0u) { mantissa += 1u; }
if mantissa >= 0x1000000u { mantissa >>= 1u; exponent += 1; }
return bitcast<f32>(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu));
}
fn weather_phase(animation: vec4<f32>, bases: vec4<u32>, snow: u32, time: vec4<f32>) -> vec2<f32> {
if snow == 0u {
let ticks = time.x + animation.y;
let t = ticks + time.z;
let step = -t / 32.0;
let offset = step * animation.x;
return vec2(0.0, offset % 32.0);
}
let t = time.x + time.z;
let u_time = t * 0.01;
let du = u_time * animation.z;
let v_time = t * animation.w;
let dv = v_time * 0.001;
let u = weather_double_plus_float(bases.xy, du);
let v = weather_double_plus_float(bases.zw, dv);
let fall_time = time.y + time.z;
let fall = -fall_time / 512.0;
return vec2(u, fall + v);
}
// Atlas sampler is nearest with no mipmaps. Clamp the integer texel inside
// the sprite even when a fractional coordinate rounds up at its far edge.
fn weather_texel(uv: vec2<f32>, rect: vec4<f32>, dimensions: vec2<u32>) -> vec2<i32> {
let size = vec2<f32>(dimensions);
let origin = vec2<i32>(round(rect.xy * size));
let extent = max(vec2<i32>(round(rect.zw * size)), vec2(1));
let local = min(vec2<i32>(floor(fract(uv) * vec2<f32>(extent))), extent - vec2(1));
return origin + local;
}
// WEATHER_MATH_END
struct WeatherTime { values: vec4<f32> }
@group(1) @binding(1) var<uniform> weather_time: WeatherTime;
@vertex fn vs_weather(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> Out {
let phase = weather_phase(animation, bases, snow, weather_time.values);
var out = entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
out.weather_rect = rect;
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
var color = textureLoad(atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
+144 -4
View File
@@ -13,6 +13,7 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -88,6 +89,47 @@ impl Base<'_> {
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
fn is_animal(id: &str) -> bool {
matches!(
id.strip_prefix("minecraft:").unwrap_or(id),
"pig"
| "cow"
| "mooshroom"
| "sheep"
| "chicken"
| "rabbit"
| "horse"
| "donkey"
| "mule"
| "zombie_horse"
| "skeleton_horse"
| "llama"
| "trader_llama"
| "goat"
| "panda"
| "polar_bear"
| "fox"
| "wolf"
| "cat"
| "ocelot"
| "parrot"
| "bee"
| "turtle"
| "armadillo"
| "frog"
| "axolotl"
| "hoglin"
| "strider"
| "camel"
| "camel_husk"
| "sniffer"
| "happy_ghast"
| "nautilus"
| "zombie_nautilus"
)
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -174,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("InLove", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
match id {
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
@@ -444,10 +489,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// Sheep.addAdditionalSaveData: its wool as it is now.
"minecraft:sheep" => {
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
map.entry(key.into()).or_insert(value);
}
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
}
// CopperGolem.addAdditionalSaveData.
"minecraft:copper_golem" => {
@@ -555,6 +600,14 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -606,11 +659,15 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.persistent = flag(tag, "PersistenceRequired");
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
m.no_gravity = flag(tag, "NoGravity");
@@ -822,6 +879,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
}
// Sheep.readAdditionalSaveData: white and unsheared when absent.
if m.species() == crate::mobs::Species::Sheep {
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
@@ -1266,6 +1328,21 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Marker".into());
}
if a.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"CustomNameVisible".into());
}
// A name from the save keeps its own shape unless it changed.
if let Some(name) = &a.custom_name
&& !tag.get("CustomName").is_some_and(|t| name_text(t) == *name)
{
put(
&mut tag,
"CustomName",
crate::scoreboard::json_to_tag(&name.component()),
);
}
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
@@ -1294,6 +1371,8 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.velocity = motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.custom_name = tag.get("CustomName").map(name_text);
a.name_visible = flag(tag, "CustomNameVisible");
if let Some(eq) = tag.get("equipment") {
for (i, slot) in ArmorStand::SLOTS.iter().enumerate() {
a.equipment[i] = eq.get(slot).and_then(item_from_nbt);
@@ -1574,6 +1653,31 @@ mod tests {
assert!(ominous_item_spawner_from_nbt(&raw).is_none());
}
#[test]
fn armor_stand_names_keep_their_formatting_across_a_save() {
let tag = Tag::compound([
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("CustomName", string("§6Hello")),
("CustomNameVisible", Tag::Byte(1)),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
let name = a.custom_name.as_ref().unwrap();
assert_eq!(name.plain(), "Hello");
assert!(name.spans[0].style.color.is_some());
assert!(a.name_visible);
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
let restored = armor_stand_from_nbt(&saved, 7).unwrap();
assert_eq!(restored.custom_name, a.custom_name);
assert!(restored.name_visible);
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
@@ -1999,3 +2103,39 @@ mod axolotl_nbt_tests {
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
}
}
#[cfg(test)]
mod sheep_nbt_tests {
use super::*;
#[test]
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:sheep"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
// Summoned white, then dyed red and sheared: the save has it now.
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
(m.extra.color, m.extra.sheared) = (14, true);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
assert_eq!(
(number(tag.get("Color")), number(tag.get("Sheared"))),
(Some(14), Some(1))
);
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
}
+29 -2
View File
@@ -107,6 +107,11 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// 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,
}
impl Default for Extras {
fn default() -> Self {
@@ -135,6 +140,8 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
agent_control: false,
}
}
}
@@ -198,6 +205,8 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"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),
_ => {}
}
}
@@ -205,7 +214,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",
"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",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -234,6 +243,9 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
@@ -451,6 +463,9 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -529,6 +544,8 @@ impl Item {
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -542,7 +559,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect => {
| AutoReconnect | NoAi | AgentControl => {
return None;
}
})
@@ -586,6 +603,12 @@ impl Item {
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -616,6 +639,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
_ => return None,
})
}
@@ -671,6 +696,8 @@ impl Item {
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ZoomKey | Item::SortKey => {}
}
}
+8 -9
View File
@@ -21,6 +21,8 @@ pub enum ItemPose {
Trident {
ticks_used: f32,
},
/// Riptide's spin (`isAutoSpinAttack`): the item held up like a fork.
SpinAttack,
Bow {
ticks_used: f32,
},
@@ -67,6 +69,12 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
* Mat4::from_rotation_y((invert * 65.0f32).to_radians())
* Mat4::from_rotation_z((invert * -85.0f32).to_radians());
}
let charging = match pose {
ItemPose::Bow { ticks_used } => {
let power = ticks_used / 20.0;
@@ -281,15 +289,6 @@ impl Default for HandHeights {
}
}
}
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HandChange {
/// The visible item is not the hand's item after the instant replacement
/// (`mainHandItem != nextMainHand`): the hand lowers.
pub changed: bool,
/// The visible item becomes the hand's item this tick.
pub replace: bool,
}
impl HandHeights {
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
/// whether the visible item differs from the held one once
File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
pub const TITLE: &str = "Play with Friends";
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push(("Invite Code".to_owned(), grey(), y));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied { "Copied" } else { "Copy Code" },
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: "Stop Sharing",
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
"No friends here yet.".to_owned()
} else {
format!("Here now: {}", view.guests.join(", "))
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, HINT, grey(), &mut y);
}
} else {
add(
&mut lines,
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, HINT, grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: "Start Sharing",
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: "Troubleshooting...",
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done",
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done",
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(TITLE);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7
View File
@@ -403,7 +403,9 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -458,8 +460,11 @@ impl Game<'_> {
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"health": p.health.health(),
"absorption": p.health.absorption(),
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
"shield_cooldown": self.shield_cooldown,
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
@@ -477,6 +482,8 @@ impl Game<'_> {
"sleeping": self.sleeping,
// The item in use: its slot and for how many ticks.
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
// The block being dug: how far along, after how many ticks.
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
// The arm swings started (LivingEntity.swing).
"swings": self.swings_started,
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
@@ -0,0 +1,140 @@
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
//! is connected, and the details an agent needs. The socket opens when the game was started for
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
//! Turning either switch in the game opens or closes it at once.
use super::Game;
use horizon_client::{Result, control};
use std::path::PathBuf;
impl Game<'_> {
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
fn agent_socket_path(&self) -> PathBuf {
self.options
.control
.clone()
.unwrap_or_else(control::default_path)
}
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
pub(super) fn start_agent_control(&mut self) -> Result<()> {
self.agent_control_on = self.settings.extras.agent_control;
if self.options.benchmark.is_some() && self.options.control.is_none() {
return Ok(());
}
if self.settings.extras.no_ai {
if self.options.control.is_some() {
log::warn!(
"Horizon: control socket not opened: {}",
horizon_core::no_ai::REFUSAL
);
}
return Ok(());
}
if let Some(path) = &self.options.control {
// Asked for on the command line: failing to listen is an error.
let server = control::Server::start(path)
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
log::info!("Horizon: control socket {}", server.path().display());
self.control = Some(server);
} else if self.agent_control_on {
self.open_agent_socket();
}
Ok(())
}
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
/// shown on the AI Agent page, not fatal.
fn open_agent_socket(&mut self) {
let path = self.agent_socket_path();
match control::Server::start(&path) {
Ok(server) => {
log::info!("Horizon: control socket {}", server.path().display());
self.control = Some(server);
}
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
}
}
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
/// closes it.
pub(super) fn sync_agent_control(&mut self) {
let wanted = self.settings.extras.agent_control;
let changed = wanted != self.agent_control_on;
self.agent_control_on = wanted;
let open = self
.control
.as_ref()
.is_some_and(|c| !c.path().as_os_str().is_empty());
if self.settings.extras.no_ai || (changed && !wanted) {
if open {
log::info!("Horizon: control socket closed");
self.control = None;
}
} else if changed && wanted && self.control.is_none() {
self.open_agent_socket();
}
}
/// An agent's command arrived.
pub(super) fn agent_command_seen(&mut self) {
self.agent_last = Some(std::time::Instant::now());
}
/// The AI Agent page's lines: the state, then where agents connect.
pub(super) fn agent_status_lines(&self) -> Vec<String> {
let server = self
.control
.as_ref()
.filter(|c| !c.path().as_os_str().is_empty());
let mut lines = vec![match server {
Some(s) if s.clients() > 0 => {
let ago = self
.agent_last
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
.unwrap_or_default();
format!(
"Connected: {} agent connection{}{ago}",
s.clients(),
if s.clients() == 1 { "" } else { "s" }
)
}
Some(_) => "Waiting for an agent.".into(),
None => "Off: agents can't reach this game.".into(),
}];
if let Some(s) = server {
if self.options.control.is_some() && !self.agent_control_on {
lines.push("Opened for this session by the program that started the game.".into());
}
// The full path is too wide for the page: Copy Connection Details has it.
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
lines.push(format!(
"Socket: {name} (copy the details for its full path)"
));
}
lines.push("Local only: agents on this computer, never the network.".into());
lines
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
/// and an MCP client's config for this game.
pub(super) fn agent_details(&self) -> String {
let socket = self.agent_socket_path();
let ctl = std::env::current_exe()
.unwrap_or_default()
.with_file_name(if cfg!(windows) {
"horizon-ctl.exe"
} else {
"horizon-ctl"
});
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
format!(
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
socket.display(),
ctl.display(),
socket.display(),
serde_json::to_string_pretty(&config).unwrap_or_default()
)
}
}
+16 -6
View File
@@ -9,18 +9,28 @@ impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
continue;
}
let pos = self.mobs[i].position;
// Dancing: a playing jukebox within 10 blocks.
let dancing = self.world.block_entities.iter().any(|(k, e)| {
matches!(e, BlockEntity::Jukebox(j) if j.playing)
&& (f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
<= 100.0
let jukeboxes = jukeboxes.get_or_insert_with(|| {
self.world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
.map(|(k, _)| *k)
.collect()
});
let dancing = jukeboxes.iter().any(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
<= 100.0
});
self.mobs[i].extra.flag =
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
+186 -7
View File
@@ -8,12 +8,13 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::{Mob, Part, anim::Poses},
trims::{TrimOverride, TrimRegistry, TrimTexture},
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
use std::{collections::HashMap, sync::Arc};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
/// Armor stand models and the humanoid layers of every equipment asset.
pub(super) struct ArmorStandAssets {
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
pub player: HashMap<&'static str, Part>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
/// asset id -> `trim_overrides`.
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
pub trims: TrimRegistry,
/// The resources the trim textures are made from.
pub root: PathBuf,
}
impl ArmorStandAssets {
pub fn load(
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
player.insert("elytra", part);
}
let mut equipment = HashMap::new();
let mut trim_overrides = HashMap::new();
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
for e in entries.flatten() {
let path = e.path();
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
);
}
}
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
equipment.insert(format!("minecraft:{name}"), kinds);
}
}
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
layers,
player,
equipment,
trim_overrides,
trims: TrimRegistry::load(root),
root: root.to_owned(),
}
}
/// Trim data of reloaded resources (the models stay).
pub fn reload_trims(&mut self, root: &std::path::Path) {
let fresh = Self::load(root, None);
self.trim_overrides = fresh.trim_overrides;
self.trims = fresh.trims;
self.root = fresh.root;
}
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
let overrides = self
.trim_overrides
.get(asset)
.map_or(&[][..], Vec::as_slice);
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
}
}
impl Game<'_> {
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
/// each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
self.stack_armor_layers(&self.armor)
}
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
pub(super) fn stack_armor_layers(
&self,
armor: &[Option<ItemStack>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
self.armor_layers(
armor
.each_ref()
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
)
}
/// `HumanoidArmorLayer` for another player's model: the armour it wears
/// (`extra.head` .. `extra.feet`).
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
let e = &m.extra;
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
self.armor_layers(
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
)
}
/// The layers of the armour items `worn`, head to feet.
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
/// The layers of the armour items `worn` (with their stacks when known,
/// for trims), head to feet.
fn armor_layers(
&self,
worn: [Option<(&str, Option<&ItemStack>)>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
let assets = &self.armor_stand_assets;
let mut out = Vec::new();
for (item, layer) in worn
.into_iter()
.zip(["helmet", "chestplate", "leggings", "boots"])
{
let Some(item) = item else {
let Some((item, stack)) = item else {
continue;
};
let Some(asset) = self
@@ -138,9 +183,117 @@ impl Game<'_> {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
.and_then(|s| assets.trim_texture(&asset, kind, s))
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
{
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
}
}
out
}
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
/// (the player's armour and the armour stands'): each generated texture
/// goes into an atlas trim slot, a free one, else the least recently
/// drawn.
pub(super) fn prepare_trims(&mut self) {
use horizon_client::hud::TRIM_SLOTS;
let assets = &self.armor_stand_assets;
let items = self.res.items();
let mut wanted: Vec<TrimTexture> = Vec::new();
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
let stacks = self
.armor
.iter()
.chain(guests.flat_map(|g| g.armor.iter()))
.zip(
["helmet", "chestplate", "leggings", "boots"]
.into_iter()
.cycle(),
)
.chain(self.armor_stands.iter().flat_map(|a| {
[5usize, 4, 3, 2]
.map(|slot| &a.equipment[slot])
.into_iter()
.zip(["helmet", "chestplate", "leggings", "boots"])
}));
for (stack, layer) in stacks {
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
continue;
};
let Some(asset) = items
.get(&stack.item)
.and_then(|i| i.equip_asset.as_deref())
else {
continue;
};
let kind = if layer == "leggings" {
"humanoid_leggings"
} else {
"humanoid"
};
if let Some(t) = assets.trim_texture(asset, kind, stack)
&& !wanted.contains(&t)
{
wanted.push(t);
}
}
for t in wanted {
let name = t.sprite();
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
let sprites = &self.gui.atlas.sprites;
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
let e = self.trim_slots.remove(i);
self.trim_slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
continue;
};
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
Some(free) => free,
None => {
let (old, slot) = self.trim_slots.remove(0);
self.gui.atlas.sprites.remove(&old);
slot
}
};
let Some(area) = self
.gui
.atlas
.sprites
.get(&format!("horizon/trim{slot}"))
.copied()
else {
continue;
};
if w > area.w || h > area.h {
continue;
}
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
self.map_patches.push(horizon_client::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
let rect = horizon_client::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
};
self.gui.atlas.sprites.insert(name.clone(), rect);
self.trim_slots.push((name, slot));
}
}
/// `ArmorStandItem.useOn`.
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
@@ -432,6 +585,10 @@ impl Game<'_> {
(false, false) => (0.5, 1.975),
};
let edge = (width + height + width) / 3.0 * 4.0;
// 26.3 compares the size with RenderSystem.DEFAULT_DEPTH_CLEAR_VALUE,
// which is 0.0 (reverse Z; the bytecode is `dconst_0`): an empty
// marker box falls back to 4, so a visible marker stand draws.
// Holograms stay hidden through the invisible flag instead.
let range = if edge == 0.0 { 4.0 } else { edge } * 64.0;
let dragon = a.equipment[5]
.as_ref()
@@ -560,6 +717,22 @@ impl Game<'_> {
0,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
.and_then(|t| uv_of(&t.sprite()))
{
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
trim,
0xFFFF_FFFF,
packed,
0,
out,
);
}
}
self.cull_entity_vertices(out, entity_start);
}
@@ -700,9 +873,15 @@ impl Game<'_> {
self.dim == super::Dim::Nether,
)),
};
let profile = stack
.extra
.as_deref()
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
.unwrap_or(serde_json::Value::Null);
self.pack.item_renderer.submit_special(
&special,
&serde_json::Value::Null,
&profile,
false,
pose,
&self.gui.atlas.sprites,
-303
View File
@@ -1,303 +0,0 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there. The generator is built and the spot found on a
//! worker, so the title keeps drawing its panorama until both are ready.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
/// The worker's result: the generator for [`SEED`], or why it failed.
type Built = std::result::Result<Arc<VanillaTerrain>, String>;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The generator, once the worker has built it; taken when it is installed.
terrain: Arc<Mutex<Option<Built>>>,
/// The generator streams the backdrop's chunks.
installed: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.pending_entry.is_none()
&& self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.world_renderer_ready
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
if !self.backdrop.installed {
let built = self
.backdrop
.terrain
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
match built {
// Still building: the panorama stays up.
None => return Ok(()),
Some(Ok(terrain)) => self.install_backdrop(terrain)?,
Some(Err(e)) => {
// The panorama stays up for good.
log::warn!("Horizon: title backdrop: {e}");
self.backdrop.installed = true;
return Ok(());
}
}
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
log::info!(
"Horizon: title backdrop ready; world rest ready: {}",
self.res.rest.get().is_some()
);
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts().air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
/// Start building the generator and finding the spot on a worker
/// (`VanillaTerrain::new` takes seconds on a cold cache).
fn start_backdrop(&mut self) -> Result<()> {
log::info!(
"Horizon: starting title backdrop after {} ms",
self.started.elapsed().as_millis()
);
let seed = seed();
let dir = self.backdrop_root()?.join(format!("{seed}"));
std::fs::create_dir_all(&dir)?;
// The streaming is replaced once the generator is built.
self.streaming.paused = true;
let mut world = World::new(self.res.facts().air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
let (terrain, spot) = (self.backdrop.terrain.clone(), self.backdrop.spot.clone());
let worldgen = self.options.resources.join("worldgen");
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
let cache = dir.join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let built = Instant::now();
let inner = match VanillaTerrain::new(&worldgen, registry, &biomes, seed) {
Ok(t) => Arc::new(t),
Err(e) => {
*terrain.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Err(e.to_string()));
return;
}
};
log::info!(
"Horizon: title backdrop generator built in {} ms",
built.elapsed().as_millis()
);
*terrain.lock().unwrap_or_else(|e| e.into_inner()) = Some(Ok(inner.clone()));
// Find (or recall) the spot.
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
log::info!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn backdrop_root(&self) -> Result<std::path::PathBuf> {
Ok(std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop"))
}
/// The built generator streams the backdrop's chunks.
fn install_backdrop(&mut self, inner: Arc<VanillaTerrain>) -> Result<()> {
let seed = seed();
let save = Arc::new(SaveDir::new(self.backdrop_root()?.join(format!("{seed}"))));
let (res, pack) = (&*self.res, &self.pack);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads().min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.cull_leaves = self.settings.horizon.cull_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
self.backdrop.installed = true;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
+16 -6
View File
@@ -15,6 +15,7 @@ use horizon_client::{
mesher::Vertex,
mobs::render::{DYE, DYE_NAMES},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
/// The beacon screen is 54 px wider than the 176-px menus it shares an origin with.
pub(super) const SHIFT: f32 = -27.0;
@@ -35,9 +36,6 @@ const PAYMENT: [&str; 5] = [
"minecraft:diamond",
"minecraft:netherite_ingot",
];
pub(super) fn is_payment(item: &str) -> bool {
PAYMENT.contains(&item)
}
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
Some(match i {
@@ -171,7 +169,7 @@ impl Game<'_> {
}
}
}
fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
@@ -255,9 +253,13 @@ impl Game<'_> {
}
}
fn beacon_levels(&self) -> i32 {
let Some(pos) = self.station.as_ref().map(|s| s.pos) else {
let Some(s) = self.station.as_ref() else {
return 0;
};
if let Some(levels) = s.beacon_levels {
return levels;
}
let pos = s.pos;
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Beacon(b)) => b.levels,
_ => 0,
@@ -346,8 +348,16 @@ impl Game<'_> {
self.screen = super::Screen::Playing;
self.capture(true);
}
/// `BeaconMenu.updateEffects`: spend the payment and set the powers.
/// `BeaconMenu.updateEffects`: spend the payment and set the powers. On
/// a server, `BeaconScreen`'s confirm button sends them
/// (`ServerboundSetBeaconPacket`) and the server's menu does it.
fn confirm_beacon(&mut self, sel: [Option<usize>; 2]) {
if self.remote.is_some() {
let id = |e: Option<usize>| e.and_then(|e| self.remote_effect_network_id(EFFECTS[e].0));
let (primary, secondary) = (id(sel[0]), id(sel[1]));
self.send(S::SetBeacon(play::SetBeacon { primary, secondary }));
return;
}
let Some(s) = self.station.as_mut() else {
return;
};
@@ -24,6 +24,7 @@ impl Game<'_> {
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.item_columns = Some(Default::default());
for slot in self.world.block_entity_tickers() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
@@ -56,6 +57,8 @@ impl Game<'_> {
_ => {}
}
}
// Items move between passes: the columns hold for this one only.
self.item_columns = None;
self.update_song_volumes();
self.scan_for_hearts();
// The client ticks (particles, sounds, the turning mob) are for a
+10 -3
View File
@@ -57,7 +57,7 @@ pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
const SEAT: f64 = 0.1875 - 0.6;
pub(super) const SEAT: f64 = 0.1875 - 0.6;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -295,7 +295,7 @@ impl Game<'_> {
}
}
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
b.previous = b.position;
b.previous_yaw = b.yaw;
b.old_status = b.status;
@@ -448,9 +448,12 @@ impl Game<'_> {
pub(super) fn tick_boats(&mut self) {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if Some(self.boats[i].id) == self.riding
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
continue;
@@ -659,7 +662,11 @@ impl Game<'_> {
self.right = false;
return true;
}
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
if sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| self.ridden_by_guest(self.boats[i].id)
{
return false;
}
self.riding = Some(self.boats[i].id);
+204 -37
View File
@@ -9,6 +9,7 @@ use horizon_client::{
hud::{HudBatch, rgba},
item_data::WrittenBook,
items::ItemStack,
menu::{Kind, Menu},
text::Text,
ui,
};
@@ -89,6 +90,17 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_book(&mut self) {
// A server's lectern: LecternScreen's menu closes (closeContainer).
if matches!(
self.book,
Some(BookScreen::View {
lectern: Some(_),
..
})
) && self.remote.is_some()
{
self.remote_close_container();
}
self.book = None;
self.capture(true);
}
@@ -319,38 +331,25 @@ impl Game<'_> {
}
}
Some(BookScreen::View {
pages,
page,
lectern,
}) => {
let before = *page;
lectern: Some(key), ..
}) => lectern_page = Some(*key),
Some(BookScreen::View { pages, page, .. }) => {
if forward && *page + 1 < pages.len() {
*page += 1;
} else if !forward && *page > 0 {
*page -= 1;
}
if *page != before
&& let Some(pos) = *lectern
{
lectern_page = Some((pos, *page));
}
}
None => return,
}
// LecternScreen's page buttons click the menu's (1 back, 2 forward);
// the page turns with the server's data slot and level event 1043.
if let Some(key) = lectern_page {
self.lectern_button(key, if forward { 2 } else { 1 });
return;
}
self.audio
.play("minecraft:item.book.page_turn", None, 1.0, 1.0);
if let Some((pos, page)) = lectern_page {
self.set_lectern_page(pos, page as i32);
}
}
/// `LecternBlockEntity.setPage` and `LecternBlock.signalPageChange`.
fn set_lectern_page(&mut self, key: (i32, i32, i32), page: i32) {
if let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) {
l.page = page;
}
self.set_lectern_powered(key, true);
self.lectern_pulses.push((key, 2));
self.container_changed(key);
}
fn set_lectern_powered(&mut self, key: (i32, i32, i32), powered: bool) {
self.set_lectern_property(key, "powered", powered);
@@ -380,6 +379,7 @@ impl Game<'_> {
}
/// The 2-tick pulses of turned lecterns.
pub(super) fn tick_lecterns(&mut self) {
self.follow_lectern();
let mut done = Vec::new();
for (key, left) in &mut self.lectern_pulses {
*left -= 1;
@@ -452,6 +452,7 @@ impl Game<'_> {
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.open_book(BookScreen::View {
pages,
page,
@@ -459,24 +460,189 @@ impl Game<'_> {
});
true
}
/// `LecternMenu` "Take Book".
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
let book = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Lectern(l)) => {
l.page = 0;
l.book.take()
/// A `LecternMenu` button of the open lectern screen: on a server it is
/// sent (`handleInventoryButtonClick`); here the lectern's menu takes it
/// with the player's inventory, and the screen follows the block entity.
fn lectern_button(&mut self, key: (i32, i32, i32), id: i32) {
if self.remote.is_some() {
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
self.send(
horizon_protocol::packets::play::Serverbound::ContainerButtonClick(
horizon_protocol::packets::play::ContainerButtonClick {
container: *container,
button: id,
},
),
);
}
_ => None,
return;
}
let Some(mut menu) = self.lectern_menu(key) else {
return;
};
if let Some(book) = book
&& let Some(rest) = self.give(book)
&& let Some(eye) = self.eye_position()
for i in 0..36 {
menu.set_player_item(i, self.inventory[i].clone());
}
menu.set_player_item(40, self.offhand.clone());
menu.creative = self.creative;
menu.selected = self.selected;
menu.may_build = self.may_build();
if !menu.button(&self.menu_env(), id) {
return;
}
for i in 0..36 {
self.inventory[i] = menu.player_item(i).cloned();
}
self.offhand = menu.player_item(40).cloned();
for stack in std::mem::take(&mut menu.dropped) {
self.player_drop(stack);
}
self.lectern_menu_done(key, &menu);
self.follow_lectern();
}
/// The `book` control command: a button of the open book or lectern
/// screen (the page arrows, Take Book, Done), then its page.
pub(super) fn book_control(
&mut self,
action: Option<&str>,
) -> horizon_client::Result<serde_json::Value> {
let lectern = match &self.book {
Some(BookScreen::View { lectern, .. }) => *lectern,
Some(BookScreen::Edit { .. }) => None,
None => return Err("no book is open".into()),
};
match action {
None => {}
Some("next") => self.turn_page(true),
Some("back") => self.turn_page(false),
Some("take") => match lectern {
Some(key) => self.lectern_button(key, 3),
None => return Err("not a lectern".into()),
},
Some("done") => self.close_book(),
Some(other) => return Err(format!("unknown book button {other}").into()),
}
Ok(match &self.book {
Some(BookScreen::View {
pages,
page,
lectern,
}) => serde_json::json!({
"page": page, "pages": pages.len(), "lectern": lectern.map(|k| [k.0, k.1, k.2]),
}),
Some(BookScreen::Edit { pages, page, .. }) => {
serde_json::json!({ "page": page, "pages": pages.len(), "lectern": null })
}
None => serde_json::json!({ "closed": true }),
})
}
/// A server's `minecraft:lectern` menu: LecternScreen, its book and
/// page to come (`remote_lectern_book`, `remote_lectern_page`).
pub(super) fn remote_open_lectern(&mut self, key: (i32, i32, i32)) {
self.open_book(BookScreen::View {
pages: Vec::new(),
page: 0,
lectern: Some(key),
});
}
/// `LecternScreen.bookChanged`: the menu's book slot.
pub(super) fn remote_lectern_book(&mut self, book: Option<ItemStack>) {
if let Some(BookScreen::View {
lectern: Some(_),
pages,
page,
}) = self.book.as_mut()
{
let data = book.as_ref().and_then(ItemStack::data);
*pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
*page = (*page).min(pages.len().saturating_sub(1));
}
}
/// `LecternScreen.pageChanged`: the menu's page data slot.
pub(super) fn remote_lectern_page(&mut self, value: i32) {
if let Some(BookScreen::View {
lectern: Some(_),
pages,
page,
}) = self.book.as_mut()
{
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
*page = (value.max(0) as usize).min(pages.len().saturating_sub(1));
}
}
/// The server closed its lectern menu; false when none is open.
pub(super) fn remote_lectern_closed(&mut self) -> bool {
if !matches!(
self.book,
Some(BookScreen::View {
lectern: Some(_),
..
})
) {
return false;
}
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.book = None;
self.capture(true);
true
}
/// `LecternMenu` over the lectern at `key` (the menu engine, for the
/// local player and LAN guests alike): its book and page.
pub(super) fn lectern_menu(&self, key: (i32, i32, i32)) -> Option<Menu> {
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get(&key) else {
return None;
};
let mut menu = Menu::new(Kind::Lectern);
menu.set_slot(&self.menu_env(), 0, l.book.clone());
menu.data[0] = l.page;
Some(menu)
}
/// The lectern menu's book and page back in the block entity: a new page
/// is `LecternBlock.signalPageChange` (the 2-tick pulse, level event
/// 1043), a book gone `onBookItemRemove` (`resetBookState`).
pub(super) fn lectern_menu_done(&mut self, key: (i32, i32, i32), menu: &Menu) {
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) else {
return;
};
let (had, before) = (l.book.is_some(), l.page);
l.book = menu.slot(0).cloned();
l.page = menu.data[0];
if had && l.book.is_none() {
self.set_lectern_powered(key, false);
self.set_lectern_property(key, "has_book", false);
self.container_changed(key);
} else if menu.data[0] != before {
self.set_lectern_powered(key, true);
self.lectern_pulses.push((key, 2));
self.level_event_at(1043, [key.0, key.1, key.2], 0);
self.container_changed(key);
}
}
/// The open lectern screen follows its block entity (`LecternScreen`'s
/// menu listener): the page, and closing once the book is gone.
pub(super) fn follow_lectern(&mut self) {
let Some(BookScreen::View {
lectern: Some(key),
page,
pages,
}) = self.book.as_mut()
else {
return;
};
if self.remote.is_some() {
return;
}
match self.world.block_entities.get(key) {
Some(BlockEntity::Lectern(l)) if l.book.is_some() => {
*page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
}
_ => self.close_book(),
}
self.set_lectern_property(key, "has_book", false);
self.container_changed(key);
self.close_book();
}
pub(super) fn book_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
@@ -537,7 +703,8 @@ impl Game<'_> {
self.close_book();
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.take_lectern_book(key);
// LecternScreen's Take Book: the menu's button 3.
self.lectern_button(key, 3);
}
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
click(self);
+68 -58
View File
@@ -1,70 +1,80 @@
//! The cartography table (`CartographyTableMenu`, `CartographyTableScreen`):
//! zooming a map out with paper, cloning it with an empty map, locking it
//! with a glass pane, the post-processing on take (`MapItem.scaleMap`,
//! `lockMap`) and the preview with its error and lock sprites.
//! The cartography table (`CartographyTableMenu` on the menu engine,
//! `CartographyTableScreen`): zooming a map out with paper, cloning it with
//! an empty map, locking it with a glass pane. The engine reads the level's
//! maps through `LevelMaps`; the scaled and locked copies a take makes
//! (`MapItem.scaleMap`, `lockMap`) are registered after the click. The
//! screen draws the preview with its error and lock sprites.
use super::{
Game,
maps::{MapData, map_id},
stations::Station,
};
use horizon_client::{hud::HudBatch, items::ItemStack};
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
};
/// The level's maps as a menu sees them (`ContainerLevelAccess`): the
/// states of the maps the click can reach, and the copies it makes.
pub(super) struct LevelMaps {
states: RefCell<HashMap<i32, (bool, u8)>>,
next_id: Cell<i32>,
/// (new id, source id, scaled) for each copy made.
made: RefCell<Vec<(i32, i32, bool)>>,
}
impl Maps for LevelMaps {
fn state(&self, id: i32) -> Option<(bool, u8)> {
self.states.borrow().get(&id).copied()
}
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
let (_, s) = self.state(id)?;
let new = self.next_id.get();
self.next_id.set(new + 1);
let made = if scale {
(false, (s + 1).min(4))
} else {
(true, s)
};
self.states.borrow_mut().insert(new, made);
self.made.borrow_mut().push((new, id, scale));
Some(new)
}
}
impl Game<'_> {
/// `setupResultSlot`.
pub(super) fn cartography_result(&self, s: &Station) -> Option<ItemStack> {
let (map, extra) = (s.inputs[0].as_ref()?, s.inputs[1].as_ref()?);
let data = self.maps.data.get(&map_id(map)?)?;
match &*extra.item {
"minecraft:paper" if !data.locked && data.scale < 4 => Some(ItemStack {
count: 1,
..map.clone()
}),
"minecraft:glass_pane" if !data.locked => Some(ItemStack {
count: 1,
..map.clone()
}),
"minecraft:map" => Some(ItemStack {
count: 2,
..map.clone()
}),
_ => None,
/// The maps of `stacks` (loaded as needed) for a menu over this level.
pub(super) fn level_maps<'s>(
&mut self,
stacks: impl IntoIterator<Item = &'s ItemStack>,
) -> LevelMaps {
let ids: Vec<i32> = stacks.into_iter().filter_map(map_id).collect();
let mut states = HashMap::new();
for id in ids {
if let Some(m) = self.map(id) {
states.insert(id, (m.locked, m.scale));
}
}
LevelMaps {
states: RefCell::new(states),
next_id: Cell::new(self.peek_map_id()),
made: RefCell::new(Vec::new()),
}
}
/// `onCraftedPostProcess` for the taken result: a scaled or locked copy
/// under a new map id. Also spends the inputs' extra item kind check.
pub(super) fn cartography_post(&mut self, mut result: ItemStack) -> ItemStack {
let extra = self
.station
.as_ref()
.and_then(|s| s.inputs[1].as_ref())
.map(|e| e.item.clone());
let Some(id) = map_id(&result) else {
return result;
};
let new = match extra.as_deref() {
Some("minecraft:paper") => self.map(id).map(|m| m.scaled()),
Some("minecraft:glass_pane") => self.map(id).map(|m| m.locked_copy()),
_ => None,
};
if let Some(data) = new {
let new_id = self.new_map_id(data);
result.data_mut().map_id = Some(new_id);
/// The scaled and locked copies a menu made (`level.setMapData`).
pub(super) fn register_made_maps(&mut self, maps: LevelMaps) {
for (id, source, scale) in maps.made.into_inner() {
let Some(data) = self
.map(source)
.map(|m| if scale { m.scaled() } else { m.locked_copy() })
else {
continue;
};
let new = self.new_map_id(data);
debug_assert_eq!(new, id, "map ids follow peek_map_id");
}
result
}
/// The take sound (once per tick).
pub(super) fn cartography_taken(&mut self, pos: [i32; 3]) {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
"minecraft:ui.cartography_table.take_result",
Some(at),
1.0,
1.0,
);
}
/// `CartographyTableScreen.extractBackground` after the background.
pub(super) fn draw_cartography(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
+1 -5
View File
@@ -5,7 +5,7 @@ use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
command::Registries,
gamerules::GameRules,
hud::{HudBatch, rgba},
hud::HudBatch,
screen_draw::Measure,
text::{ClickEvent, Style, Text},
};
@@ -883,10 +883,6 @@ impl Game<'_> {
.chat
.add(&self.gui.atlas, message, None, self.ticks, focused);
}
#[allow(dead_code)]
fn chat_debug_fill(&self, batch: &mut HudBatch) {
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
/// approximated as UTC).
+49 -21
View File
@@ -294,6 +294,18 @@ impl Game<'_> {
}
// ---- entities -------------------------------------------------------
/// The level `t` is in (`Entity.level`): a server's players have their
/// own, which need not be the one in place; everything else is in the
/// level in place.
pub(super) fn target_level(&self, t: Target) -> Dim {
let conn = match t {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
_ => None,
};
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
@@ -1191,15 +1203,12 @@ impl Game<'_> {
pos: [f64; 3],
rotation: Option<[f32; 2]>,
) -> bool {
let (conn, at) = match t {
Target::Player => (self.lan_acting, self.dim),
Target::Guest(conn) => (
Some(conn),
self.command_guest(conn)
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
),
_ => (None, self.dim),
let conn = match t {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
_ => None,
};
let at = self.target_level(t);
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
return false;
};
@@ -1222,12 +1231,7 @@ impl Game<'_> {
let dest = self.entities(ctx, "destination", false)?[0];
let pos = self.target_position(dest).unwrap_or(self.source_position());
// Into the destination's level.
let level = match dest {
Target::Guest(conn) => self
.command_guest(conn)
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
_ => self.dim,
};
let level = self.target_level(dest);
// TeleportCommand.teleportToEntity: the destination's rotation too.
let rotation = Some(self.target_rotation(dest));
for &t in &targets {
@@ -2281,12 +2285,23 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
});
}
let dim = Text::literal(self.dim.id());
let p = pos.map(|v| Text::literal(v.to_string()));
let mut args = vec![
@@ -2322,7 +2337,20 @@ impl Game<'_> {
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
_ => [0.0, 0.0],
};
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
let old = self.level.world_spawn.replace(pos);
self.level.world_spawn_angle = rot;
// A player whose fallback is the world spawn (no spawnpoint of its
// own; a bed or an anchor that is gone falls back to it) respawns at
// the new one.
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
if self.spawn.map(floor) == old {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
}
let args = [
Text::literal(pos[0].to_string()),
Text::literal(pos[1].to_string()),
@@ -155,14 +155,20 @@ impl Game<'_> {
_ => s.pos,
}
}
/// Evaluate `f` with `source` as the command source.
/// Evaluate `f` with `source` as the command source, in the source's
/// level (`CommandSourceStack.getLevel`): a server puts that level in
/// place for it.
pub(in crate::game) fn with_source<T>(
&mut self,
source: Source,
f: impl FnOnce(&mut Self) -> T,
) -> T {
let previous = self.command_source.replace(source);
let back = self.enter_command_level(source.dimension);
let out = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
self.command_source = previous;
out
}
@@ -447,11 +453,13 @@ impl Game<'_> {
"at" => targets(self)?
.into_iter()
.filter_map(|t| {
// CommandSourceStack.withLevel(entity.level()).
let level = self.target_level(t);
Some(Source {
pos: self.target_position(t)?,
rot: self.target_rotation(t),
dimension: self.dim,
level: None,
dimension: level,
level: (level != self.dim).then_some(level),
..s
})
})
@@ -810,7 +810,10 @@ impl Game<'_> {
None => break,
},
};
if self.slot_get(slot) != item && self.slot_set(slot, item) {
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
// SlotAccess.set counts every slot it sets, the same item too.
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
if self.slot_set(slot, item) {
changed += 1;
}
}
@@ -248,8 +248,32 @@ impl Game<'_> {
/// Every tick: the size change, the wall the player cannot pass and
/// damage outside the border.
/// `BorderChangeListener.DelegateBorderChangeListener`'s packets: what
/// changed on the border since the guests were last told of it.
pub(in crate::game) fn lan_sync_border(&mut self, host: &mut crate::game::lan::LanHost) {
let now = horizon_client::lan::border::Sent::of(&self.border);
if let Some(sent) = host.border_sent.replace(now) {
for packet in sent.changes(&self.border) {
host.hub.broadcast(&packet);
}
}
}
pub(in crate::game) fn tick_worldborder(&mut self) {
self.border.tick();
self.player_border();
self.border_damage();
}
/// The player's movement collides with the border as it is now
/// (`CollisionGetter.borderCollision`): from inside, its walls stop it.
pub(in crate::game) fn player_border(&mut self) {
if let Some(p) = self.player.as_mut() {
p.border = Some(self.border.geometry().clone());
}
}
/// `LivingEntity.baseTick`: a player outside the border, further than
/// its safe zone, is hurt by the blocks beyond it. The game's player, or
/// a guest's in their turn (`as_guest`).
pub(in crate::game) fn border_damage(&mut self) {
let Some((px, pz, half)) = self.player.as_ref().map(|p| {
(
p.position.x,
@@ -269,20 +293,6 @@ impl Game<'_> {
let amount = (-dist * self.border.damage_per_block).floor().max(1.0) as f32;
self.hurt_player_by("minecraft:outside_border", amount);
}
// From inside the border the wall stops movement.
if self.border.distance(px, pz) >= 0.0 {
let [x, z] = self.border.clamp(px, pz, half);
if let Some(p) = self.player.as_mut() {
if x != px {
p.velocity.x = 0.0;
}
if z != pz {
p.velocity.z = 0.0;
}
p.position.x = x;
p.position.z = z;
}
}
}
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
let float2 = |v: f64| format!("{v:.2}");
+67 -3
View File
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
"chat MESSAGE send chat or a /command to the server",
"disconnect leave the server for the server list",
"respawn respawn after death on a server",
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
"quit save and exit",
];
@@ -117,6 +118,7 @@ impl Game<'_> {
self.answer(request, event_loop);
}
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
self.agent_command_seen();
self.answer(request, event_loop);
if self.pending_save.is_some() {
break;
@@ -125,6 +127,11 @@ impl Game<'_> {
if self.control.is_some() {
self.watch_events();
}
if self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
{
self.ui.options.agent_status = self.agent_status_lines();
}
}
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
@@ -165,6 +172,9 @@ impl Game<'_> {
if let Some(value) = self.events_command(&words, reply)? {
return Ok(value);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.options_control(&words, line)? {
return Ok(Some(value));
}
@@ -192,6 +202,7 @@ impl Game<'_> {
.chain(super::ui_control::HELP)
.chain(super::options::CONTROL_HELP)
.chain(super::events::HELP)
.chain(super::saves_control::HELP)
.chain(super::pad::HELP)
.collect::<Vec<_>>() }))),
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
@@ -523,13 +534,22 @@ impl Game<'_> {
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box.
Screen::Inventory
if self
.station
.as_ref()
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
{
self.station_text(Some(text), false)
}
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)
}
_ => {
return Err(
"type needs the chat screen (key t tap), the language search, the creative search or World Options".into(),
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
);
}
}
@@ -555,6 +575,10 @@ impl Game<'_> {
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
"credits" => Page::Credits,
"horizon" => Page::Horizon,
"agent" => Page::Agent,
"shader-packs" => Page::ShaderPacks,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -630,6 +654,25 @@ impl Game<'_> {
self.connect_server(arg(1)?, &name)?;
ok()
}
"credits" => {
let poem = words.get(1) == Some(&"poem");
let hold = words
.get(1 + usize::from(poem))
.map(|s| s.parse::<f32>())
.transpose()?;
if self.screen != Screen::Win {
let finish = if self.screen == Screen::Options {
super::win::Finish::Options
} else {
super::win::Finish::Closed
};
self.open_win_screen(poem, finish);
}
if let Some(scroll) = hold {
self.hold_win_screen(scroll);
}
ok()
}
"respawn" => {
if self.remote.is_none() || self.screen != Screen::Dead {
return Err("respawn needs the death screen on a server".into());
@@ -712,6 +755,9 @@ impl Game<'_> {
}
fn screen_name(&self) -> &'static str {
if self.screen == Screen::Win {
return "win";
}
if self.title.is_some() {
return "title";
}
@@ -727,7 +773,9 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
}
}
@@ -786,6 +834,12 @@ impl Game<'_> {
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
"on_ground": p.ground.on_ground,
"health": p.health.health(),
// The maximum the HUD's hearts last read (its cached value).
"max_health": p
.attributes
.lookup(p.attributes.key("minecraft:max_health").as_ref())
.and_then(|h| p.attributes.instance(&h).ok())
.map(|i| i.cached_value()),
"food": p.health.food.food,
})
});
@@ -838,6 +892,8 @@ impl Game<'_> {
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
"far_detail": self.settings.horizon.far_detail.name(),
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
"pending_observations": self.lod_observe.len(),
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
});
@@ -888,13 +944,13 @@ impl Game<'_> {
"chunks": self.world.chunks.len(),
"mobs": self.mobs.len(),
"block_entities": self.world.block_entities.len(),
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
"pending_chunks": self.streaming.pending() + self.pending_uploads.len() + self.remote_backlog(),
"generated_chunks": self.streaming.generated_total,
"meshed_sections": self.streaming.meshed_total,
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
@@ -917,8 +973,16 @@ impl Game<'_> {
status["heard"] = json!(self.audio.heard);
// 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],
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The world border this side knows of.
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
status
}
}
+46 -9
View File
@@ -330,6 +330,10 @@ impl Game<'_> {
"held": m.extra.main_hand.as_deref(),
"mount": m.extra.mount,
"rider": m.extra.rider,
// A player's input this tick, and whether it steers.
"ridden": m.rider.map(|r| r.steer),
"controlled": horizon_client::mobs::equine::controlled(m),
"saddled": m.extra.saddled,
"enchants": horizon_client::mobs::gear::gear(m).map(|g| g.slots.iter().map(|s| s.iter().map(|(e, l)| format!("{e}:{l}")).collect::<Vec<_>>()).collect::<Vec<_>>()),
"ints": m.extra.ints,
"pois": [m.extra.home, m.extra.bed, m.extra.meeting],
@@ -339,8 +343,12 @@ impl Game<'_> {
"on_ground": m.on_ground,
"variant": m.extra.variant,
"color": m.extra.color,
"sheared": m.extra.sheared,
"burning": m.fire_ticks > 0,
"size": m.extra.size,
"leash": m.extra.leash.map(|h| format!("{h:?}")),
"tame": m.extra.tame,
"sitting": m.extra.sitting,
"temper": m.extra.variant3,
"chested": m.extra.chested,
"head": m.extra.head.as_deref(),
@@ -390,6 +398,7 @@ impl Game<'_> {
player,
others,
others_grid: None,
others_index: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
game_time: self.world.game_time,
day_time: self.world.day_time,
@@ -402,7 +411,7 @@ impl Game<'_> {
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: self.mob_teams(),
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
difficulty: self.local_difficulty(
self.player
@@ -499,13 +508,26 @@ impl Game<'_> {
// The level random, lent to the mobs (brain behaviours draw from
// it) and taken back after their ticks.
let mut scratch = self.world.random.clone();
let mut pushes = Vec::new();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut keep = Vec::with_capacity(mobs.len());
let mut grid =
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
// All but the snapshot is the same for every mob of the tick, so
// build it once: the player view and the teams scan every UUID.
let base = self.mob_world_base(&[]);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
}
for i in 0..mobs.len() {
let mut w = self.mob_world_base(&snapshot);
w.others_grid = Some(&grid);
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
..base.clone()
};
let m = &mut mobs[i];
w.nearest_player = super::natural_spawning::nearest_player(
&players,
@@ -589,7 +611,11 @@ impl Game<'_> {
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
{
bindings.retain(|id, _| mobs.iter().any(|m| m.id == *id));
// A set: a scan of every mob for each binding is quadratic, and a
// server's loaded mobs run into the thousands.
let live: horizon_client::fast_hash::FxHashSet<u32> =
mobs.iter().map(|m| m.id).collect();
bindings.retain(|id, _| live.contains(id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
@@ -2107,15 +2133,22 @@ impl Game<'_> {
let from = pl.position;
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
}
// To where the pearl started its last tick (oldPosition).
let to = p.previous;
if let Some(pl) = self.player.as_mut() {
pl.position = at;
pl.position = to;
pl.velocity = Vec3::ZERO;
pl.fall_distance = 0.0;
pl.teleported = true;
}
self.previous_position = at;
self.previous_position = to;
self.hurt_player_by("minecraft:ender_pearl", 5.0);
self.audio
.play("minecraft:entity.player.teleport", Some(atp), 1.0, 1.0);
self.audio.play(
"minecraft:entity.player.teleport",
Some([to.x, to.y, to.z]),
1.0,
1.0,
);
}
}
(ProjectileKind::SmallFireball, hit) => match hit {
@@ -2922,6 +2955,10 @@ impl Game<'_> {
let scale = f64::from(self.settings.entity_distance);
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
if mob.invisible {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
+1 -1
View File
@@ -753,7 +753,7 @@ impl Game<'_> {
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
if m.removed || m.has_effect("minecraft:invisibility") {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
let pos = [
@@ -291,12 +291,12 @@ impl Game<'_> {
used = self.fixture_use_item(origin, op)?;
} else if let Some(op) = case.get("respawn_at") {
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
let config = super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos: [origin.x, origin.y, origin.z],
yaw: 0.0,
forced: op["forced"].as_bool().unwrap_or(false),
};
let config = super::respawn_anchor::RespawnConfig::wrapped(
self.dim,
[origin.x, origin.y, origin.z],
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
op["forced"].as_bool().unwrap_or(false),
);
let found =
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
used.push(match found {
@@ -1736,7 +1736,9 @@ impl Game<'_> {
mob.head_pitch = old.head_pitch;
mob.persistent = old.persistent;
mob.custom_name = old.custom_name.clone();
mob.custom_name_text = old.custom_name_text.clone();
mob.name_visible = old.name_visible;
mob.invisible = old.invisible;
mob.no_ai = old.no_ai;
mob.silent = old.silent;
mob.no_gravity = old.no_gravity;
+3
View File
@@ -2697,9 +2697,12 @@ impl Game<'_> {
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
data: 0,
meta,
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
})
}
}
@@ -8,6 +8,15 @@ use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
Main,
Off,
/// An `armor` index (head first).
Armor(usize),
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -314,36 +323,29 @@ impl Game<'_> {
pub(super) fn mending(&mut self, amount: u32) -> u32 {
let mut amount = amount;
loop {
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
let mut candidates: Vec<(bool, usize)> = Vec::new();
if let Some(s) = &self.inventory[self.selected]
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((false, self.selected));
}
for (i, s) in self.armor.iter().enumerate() {
if let Some(s) = s
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((true, i));
}
}
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
// chest and head (`armor` holds head first).
let mending = |s: &Option<ItemStack>| {
s.as_ref()
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
};
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
.into_iter()
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
.filter(|&slot| mending(self.mend_slot(slot)))
.collect();
if candidates.is_empty() {
return amount;
}
let (armor, i) = candidates[self
let slot = candidates[self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0) as usize];
let stack = if armor {
self.armor[i].clone()
} else {
self.inventory[i].clone()
let Some(stack) = self.mend_slot(slot).clone() else {
return amount;
};
let Some(stack) = stack else { return amount };
let ctx = EffectContext {
items: Some(self.res.items()),
..Default::default()
@@ -356,12 +358,7 @@ impl Game<'_> {
amount as f32,
) as u32;
let repaired = to_repair_xp.min(stack.damage);
let slot = if armor {
&mut self.armor[i]
} else {
&mut self.inventory[i]
};
if let Some(s) = slot.as_mut() {
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
s.damage -= repaired;
}
if repaired == 0 || to_repair_xp == 0 {
@@ -374,6 +371,20 @@ impl Game<'_> {
amount = remaining;
}
}
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
match slot {
MendSlot::Main => &self.inventory[self.selected],
MendSlot::Off => &self.offhand,
MendSlot::Armor(i) => &self.armor[i],
}
}
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
match slot {
MendSlot::Main => &mut self.inventory[self.selected],
MendSlot::Off => &mut self.offhand,
MendSlot::Armor(i) => &mut self.armor[i],
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
+96 -52
View File
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug)]
enum GatewayJob {
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
Find { gateway: [i32; 3] },
/// A known exit (exact, or findExitPosition around it).
@@ -97,8 +97,58 @@ impl EndState {
}
}
impl EndState {
/// The eyes' entity ids, for a server to number them.
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
self.eyes.iter_mut().map(|e| &mut e.id)
}
/// The numbered eyes as a server's clients track them: (id, position, motion).
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
self.eyes
.iter()
.filter(|e| e.id != 0)
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
}
/// `handleAddEntity` for an eye of ender on a server's client: it flies
/// where the server says.
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
self.eyes.retain(|e| e.id != id);
let at = glam::DVec3::from_array(at);
self.eyes.push(EyeOfEnder {
id,
position: at,
previous: at,
velocity: glam::DVec3::from_array(velocity),
target: at,
life: 0,
survive: false,
});
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
for e in &mut self.eyes {
e.previous = e.position;
e.life += 1;
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
}
}
pub(super) fn remove_remote_eye(&mut self, id: u32) {
self.eyes.retain(|e| e.id != id);
}
pub(super) fn clear_remote_eyes(&mut self) {
self.eyes.clear();
}
/// Where each eye is, for `status`.
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
}
}
/// The EyeOfEnder entity.
struct EyeOfEnder {
/// Its entity id once a server's clients track it (0 until then).
id: u32,
position: glam::DVec3,
previous: glam::DVec3,
velocity: glam::DVec3,
@@ -138,47 +188,12 @@ impl Game<'_> {
}
}
/// SkyRenderer.buildEndSky: six faces of end_sky.png tiled 16 times,
/// tinted 0x282828. The atlas does not wrap, so each face is 16×16 quads.
pub(super) fn end_sky_geometry(&self) -> Vec<Vertex> {
let mut out = Vec::with_capacity(6 * 256 * 4);
let Some(r) = self.gui.atlas.sprites.get("environment/end_sky").copied() else {
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
pub(super) fn end_flash_geometry(&self) -> Vec<Vertex> {
let mut out = Vec::new();
// The old combined sky/flash draw also skipped the flash without a sky sprite.
if !self.gui.atlas.sprites.contains_key("environment/end_sky") {
return out;
};
use std::f32::consts::FRAC_PI_2;
let faces = [
glam::Mat4::IDENTITY,
glam::Mat4::from_rotation_x(FRAC_PI_2),
glam::Mat4::from_rotation_x(-FRAC_PI_2),
glam::Mat4::from_rotation_x(std::f32::consts::PI),
glam::Mat4::from_rotation_z(FRAC_PI_2),
glam::Mat4::from_rotation_z(-FRAC_PI_2),
];
let step = 200.0 / 16.0;
for pose in faces {
for a in 0..16 {
for b in 0..16 {
let (x0, z0) = (-100.0 + step * a as f32, -100.0 + step * b as f32);
let (x1, z1) = (x0 + step, z0 + step);
let corners = [
(x0, z0, [r.u0, r.v0]),
(x0, z1, [r.u0, r.v1]),
(x1, z1, [r.u1, r.v1]),
(x1, z0, [r.u1, r.v0]),
];
for (x, z, uv) in corners {
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 2,
color: 0xFF28_2828,
light: 0,
});
}
}
}
}
// SkyRenderer.renderEndFlash: the end_flash sprite at height 100, scale 60.
let intensity = self.end.flash.intensity(self.clock.fraction() as f32);
@@ -377,7 +392,10 @@ impl Game<'_> {
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
if portal {
if portal && self.dim == Dim::End && !self.level.seen_credits {
// EndPortalBlock.entityInside: the first exit shows the credits.
self.show_end_credits()?;
} else if portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
@@ -448,7 +466,15 @@ impl Game<'_> {
f64::from(END_SPAWN[2]) + 0.5,
));
}
// Leaving the End: the respawn position (the world spawn here).
// Leaving the End: the respawn position, a bed's found once it has
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
return Some(Vec3::new(
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
}
@@ -519,7 +545,16 @@ impl Game<'_> {
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
}
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok((at, Some(yaw)));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
}
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
@@ -735,6 +770,20 @@ impl Game<'_> {
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
self.end.gateway_cooldown = 40;
let (job, destination) = self.gateway_job(gateway);
let Some(player) = self.player.take() else {
return Ok(());
};
self.end.job = Some(job);
self.end_travel = true;
self.travelling = Some((player, destination));
Ok(())
}
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let known = self.gateway_exits().get(&gateway).copied();
let job = match known {
Some((exit, exact)) => GatewayJob::Known { exit, exact },
@@ -760,13 +809,7 @@ impl Game<'_> {
Vec3::new(d.x, 75.0, d.y)
}
};
let Some(player) = self.player.take() else {
return Ok(());
};
self.end.job = Some(job);
self.end_travel = true;
self.travelling = Some((player, destination));
Ok(())
(job, destination)
}
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
@@ -803,7 +846,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
@@ -1030,6 +1073,7 @@ impl Game<'_> {
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.end.eyes.push(EyeOfEnder {
id: 0,
position: start,
previous: start,
velocity: glam::DVec3::ZERO,
+29 -1
View File
@@ -224,7 +224,7 @@ impl Game<'_> {
}
set.entries().map(|(k, _)| k.0).collect()
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`.
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
/// distance and exposure, through a raised shield (`hurtServer` blocks
@@ -460,6 +460,34 @@ impl Game<'_> {
);
}
self.tnts = tnts;
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
// then the push from their eyes (0.85 of their height).
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
for id in ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
let bounds = self.carts[i].bounds();
let feet = self.carts[i].position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
self.hurt_cart(i, damage(dist, exposure), false, true);
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
continue;
};
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
c.velocity = Vec3::new(
c.velocity.x + dir[0] * power,
c.velocity.y + dir[1] * power,
c.velocity.z + dir[2] * power,
);
}
}
/// `Level.explode` on the server: positions, entities, blocks, fire; then
/// the client's explosion sound.
+59 -12
View File
@@ -1,4 +1,5 @@
//! Play with friends, host side: "Invite friends" opens the world to LAN if
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
@@ -6,7 +7,12 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, front::FriendsView, text::Text};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
text::Text,
};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
@@ -52,10 +58,12 @@ impl Game<'_> {
if self.friends.is_some() {
return Ok(());
}
if self.remote.is_some() || self.title.is_some() {
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan.is_none();
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
@@ -153,7 +161,7 @@ impl Game<'_> {
}
if announce {
self.system_message(Text::literal(
"Your invite code is ready in the pause menu: copy it and send it to your friends.",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
}
@@ -169,16 +177,55 @@ impl Game<'_> {
}
}
/// What the pause menu shows.
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the Shader Packs page opens its folder.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_folder(std::path::Path::new(url));
}
}
Action::Done => self.screen = super::Screen::Paused,
}
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = self
.lan
.as_ref()
.map(|h| h.hub.guests.iter().map(|g| g.name.clone()).collect())
.unwrap_or_default();
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none(),
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
+6 -3
View File
@@ -6,9 +6,12 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
//! the world in the frame instead (the escape hatch while the network
//! player's side catches up with the in-frame one).
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
//! server's players reach parity with the in-frame world: on the server
//! thread, signs open no editor, books don't open, a totem doesn't save its
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
//! same code either way.
use super::{
Game, Options, PackResources, Resources, UiResources,
lan::{LanSettings, Owner},
+18 -1
View File
@@ -9,12 +9,13 @@ use horizon_assets::item_model::{
};
use horizon_client::{
block_entity_render::{diffuse_lights, vertex_light},
hud::HudBatch,
hud::{HudBatch, Rect},
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
items::ItemStack,
mesher::Vertex,
};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap};
/// The game's answers to what the resolver cannot read from the stack.
pub(super) struct Env<'a, 'g> {
@@ -126,6 +127,8 @@ pub(super) fn local_time(pattern: &str) -> String {
/// Banner and shield pattern textures from the banner pattern registry.
pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -138,6 +141,18 @@ impl SpecialHost for Host<'_> {
.pattern(pattern)
.map(|_| self.banners.shield_texture(pattern))
}
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
}
}
/// `ItemOwner` of an entity holding or showing an item.
@@ -177,6 +192,8 @@ impl Game<'_> {
pub(super) fn item_host(&self) -> Host<'_> {
Host {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
}
}
/// The client level as item models read it.
+137 -85
View File
@@ -10,11 +10,11 @@ use horizon_client::{
self, Lan, MAX_PLAYERS, NetConfig,
chunks::ChunkEncoder,
data::SyncData,
entity::EntityState,
entity::{EntityState, States},
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::Mob,
mobs::{Mob, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -66,9 +66,14 @@ pub(super) struct LanHost {
/// whose respawn replaces their waypoint.
pub waypoints: std::collections::HashMap<String, lan::waypoints::Waypoints>,
pub waypoint_dead: std::collections::HashSet<u128>,
/// The world border as the guests were last told of it (lan/border.rs).
pub border_sent: Option<lan::border::Sent>,
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
}
/// The player who owns an integrated server's world: a guest whose state is
@@ -88,7 +93,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
/// comes after AbstractPiglin's zombification flag (17).
fn baby_index(kind: &str) -> Option<u8> {
pub(super) fn baby_index(kind: &str) -> Option<u8> {
match kind {
"minecraft:piglin" => Some(17),
"minecraft:cow"
@@ -136,8 +141,13 @@ fn baby_index(kind: &str) -> Option<u8> {
/// A mob's synched data as ServerEntity sends it (only the values that differ
/// from the defaults the client constructs), with vanilla's entry indices:
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
let mut meta = Vec::new();
/// `defaults` gets the defaults of the entries that can go back to theirs.
fn mob_meta(
m: &Mob,
hub: &Hub,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
@@ -157,15 +167,18 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
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;
if flags != 0 {
meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
if m.is_baby()
&& let Some(index) = baby_index(kind)
{
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
if let Some(index) = baby_index(kind) {
if m.is_baby() {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
@@ -176,6 +189,7 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
if value != 0 {
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
}
"minecraft:cat" | "minecraft:wolf" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
@@ -198,6 +212,13 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
if let Some(v) = id(registry, name) {
meta.push((index, serializer, Value::Id(v)));
}
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
let red = i32::from(horizon_client::mobs::tame::RED);
if i32::from(e.color & 15) != red {
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
}
defaults.push((collar, serializer::INT, Value::Int(red)));
}
"minecraft:horse"
| "minecraft:donkey"
@@ -280,14 +301,16 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
}
_ => {}
}
meta
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
/// off hand, feet, legs, chest, head, body, saddle).
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
fn mob_equipment(
m: &Mob,
hub: &mut Hub,
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
let mut out = Vec::new();
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
@@ -316,7 +339,6 @@ fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::it
});
out.push((7, hub.stack(&Some(stack))));
}
out
}
pub const HELP: &[&str] = &[
@@ -387,9 +409,14 @@ impl Game<'_> {
"placed": g.placed,
"loaded": g.loaded,
"game_mode": g.game_mode,
// ItemCooldowns: the ticks the shield an axe disabled stays down.
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
"sneaking": g.sneaking,
"sleeping": g.bed,
"health": g.health,
// The server's body: its air supply and golden hearts.
"air": g.motion.as_ref().map(|m| m.air),
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
// FoodData: food level, saturation and exhaustion.
"food": [
f64::from(g.food),
@@ -536,7 +563,9 @@ impl Game<'_> {
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
border_sent: None,
commands: Vec::new(),
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -740,77 +769,64 @@ impl Game<'_> {
equipment,
health: p.health.health(),
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
living_flags: living_flags(self.using),
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
bed: self.sleeping,
})
}
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub) -> Vec<EntityState> {
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len());
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
fn lan_entities(
&self,
host_entity: Option<i32>,
mob_players: &HashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
let kind = m.kind.id.as_str();
let meta = mob_meta(m, hub);
let equipment = mob_equipment(m, hub);
let attributes = self.mob_attributes(m);
let (yaw, pitch) = (m.yaw, m.head_pitch);
out.push(EntityState {
id: m.id as i32,
uuid: entity_uuid(m.id),
kind: kind.to_owned(),
position: [m.position.x, m.position.y, m.position.z],
yaw,
pitch,
head_yaw: m.head_yaw,
on_ground: m.on_ground,
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
data: 0,
meta,
equipment,
attributes,
precise: false,
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity),
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
}
for d in &self.dropped {
let stack = hub.stack(&Some(d.stack.clone()));
out.push(EntityState {
id: d.id as i32,
uuid: entity_uuid(d.id),
kind: "minecraft:item".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
yaw: 0.0,
pitch: 0.0,
head_yaw: 0.0,
on_ground: d.on_ground,
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
data: 0,
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
});
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
out.push(EntityState {
id: o.id as i32,
uuid: entity_uuid(o.id),
kind: "minecraft:experience_orb".to_owned(),
position: [o.position.x, o.position.y, o.position.z],
yaw: 0.0,
pitch: 0.0,
head_yaw: 0.0,
on_ground: o.on_ground,
velocity: [o.velocity.x, o.velocity.y, o.velocity.z],
data: 0,
// ExperienceOrb.DATA_VALUE.
meta: vec![(8, serializer::INT, Value::Int(o.value as i32))],
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
});
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
e.position = [o.position.x, o.position.y, o.position.z];
e.on_ground = o.on_ground;
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
// ExperienceOrb.DATA_VALUE.
e.meta
.push((8, serializer::INT, Value::Int(o.value as i32)));
}
self.lan_object_states(host_entity, hub, &mut out);
out
self.lan_object_states(host_entity, hub, out);
}
/// Run the hub for this tick and apply what the guests did.
@@ -829,24 +845,46 @@ impl Game<'_> {
self.send_admin_feedback(&mut host, None);
}
let state = self.lan_host_state(&host);
// An animal whose rider left the game (or the level) stops taking
// its input.
for m in &mut self.mobs {
if m.rider.is_some()
&& self.riding != Some(m.id)
&& !host.hub.guests.iter().any(|g| g.vehicle == Some(m.id))
{
m.rider = None;
}
}
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
let entities = if has_guests {
let mut states = std::mem::take(&mut host.states);
if has_guests {
let host_entity = state.as_ref().map(|s| s.entity_id);
self.lan_assign_ids();
self.lan_riding(host_entity, &mut host.hub.riding);
self.lan_entities(host_entity, &mut host.hub)
let guests: Vec<(u32, i32)> = host
.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
} else {
Vec::new()
};
states.clear();
}
// ServerEntity.sendChanges clears needsSync once it has looked.
for m in &mut self.mobs {
m.needs_sync = false;
}
let entities = states.as_slice();
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities: &entities,
entities,
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
@@ -856,6 +894,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
@@ -887,7 +926,7 @@ impl Game<'_> {
self.lan_log_event(event);
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), &entities, spawn);
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
@@ -952,6 +991,7 @@ impl Game<'_> {
}
}
}
host.states = states;
}
self.lan_flush_effects(&mut host);
self.tick_guests(&mut host);
@@ -961,6 +1001,8 @@ impl Game<'_> {
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
self.lan_sync_scoreboard(&mut host);
self.lan_sync_border(&mut host);
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
@@ -1011,15 +1053,15 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
let theirs;
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
log::error!("could not open {}: {e}", level.id());
return;
}
// A dedicated server has no host player.
theirs = self.lan_entities(None, &mut host.hub);
&theirs[..]
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
theirs.as_slice()
} else {
entities
};
@@ -1037,6 +1079,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.place_new_player(conn, &mut ctx);
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
@@ -1051,8 +1094,14 @@ impl Game<'_> {
}
for e in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
// The acting guest does not predict the egg's teleport particles.
let except = if *id == 2015 { None } else { self.lan_acting };
// The acting guest does not predict the egg's teleport
// particles, nor bone meal's growth (BoneMealItem.useOn's
// levelEvent names no player).
let except = if matches!(*id, 2015 | 1505) {
None
} else {
self.lan_acting
};
self.lan_events
.push((*id, [pos.x, pos.y, pos.z], *data, except));
}
@@ -1253,7 +1302,7 @@ impl Game<'_> {
};
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
for m in host.models.values() {
for (conn, m) in &host.models {
let entity_start = out.len();
let light = self.world.light(
m.position.x.floor() as i32,
@@ -1268,7 +1317,10 @@ impl Game<'_> {
&lookup,
packed,
self.res.mob_data(),
&self.worn_armor_layers(m),
&host.hub.guest(*conn).map_or_else(
|| self.worn_armor_layers(m),
|g| self.stack_armor_layers(&g.armor),
),
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
+258 -23
View File
@@ -5,7 +5,7 @@
//! blocks, attacking and the commands all behave exactly as they do for the
//! host, and every world change they cause reaches all players through the
//! block log, the level events and the entity tracker.
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
destroy_delay: u32,
using: Option<(usize, u32)>,
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
attack_ticks: u32,
/// The main hand's item at the guest's last tick (`Game::attack_item`).
attack_item: Option<std::sync::Arc<str>>,
@@ -49,10 +51,18 @@ pub(super) struct GuestPlay {
/// Swings the guest's actions started this tick, for the players around
/// (`LivingEntity.swing` sends ClientboundSwingAnimationPacket).
swings: u32,
/// The guest's riding jump charge (`LocalPlayer.jumpRidingTicks`).
mount: super::mounts::MountState,
/// The trading screen the guest has open.
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
/// The inventory menu and the crafting table the guest crafts in.
pub(super) crafting: super::lan_crafting::GuestCrafting,
/// The workstation menu the guest has open (game/lan_stations.rs).
pub(super) station: Option<super::lan_stations::GuestStation>,
/// The mount inventory the guest has open (game/lan_mount_menu.rs).
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
/// The lectern the guest reads (game/lan_stations.rs).
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
/// What the guest's body was given for its effects and enchantments.
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
@@ -109,6 +119,7 @@ pub(super) struct GuestSwap {
destroy_delay: u32,
using: Option<(usize, u32)>,
use_delay: u32,
pub(super) shield_cooldown: u32,
attack_ticks: u32,
attack_item: Option<std::sync::Arc<str>>,
swing: horizon_client::first_person::SwingState,
@@ -209,6 +220,18 @@ impl Game<'_> {
host.hub.guests[i].position[2],
);
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
// The first body after joining burns, breathes and falls on as saved.
if let Some(saved) = host.hub.guests[i].loaded_body.take()
&& let Some(m) = host.hub.guests[i].motion.as_mut()
{
saved.apply(m);
}
// The saved golden hearts go on the first body of a player who
// has been here before.
let saved = host.hub.guests[i].saved_absorption.take();
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
let _ = m.internal_set_absorption(amount);
}
let kept = host.hub.guests[i].logs_kept.take();
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
m.hurt_log = hurts;
@@ -282,13 +305,16 @@ impl Game<'_> {
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
let swing = std::mem::replace(&mut self.swing, play.swing);
std::mem::swap(&mut self.mount, &mut play.mount);
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
let riding = self.riding.take();
// The guest's vehicle (Entity.vehicle) for its turn.
let riding = std::mem::replace(&mut self.riding, g.vehicle);
let host_station = self.station.take();
// The guest's bed, sleep counter and respawn point; the host's
// respawn point, anchor and action bar stay theirs.
@@ -299,6 +325,7 @@ impl Game<'_> {
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
);
let respawn = (self.respawn_config.take(), self.level.spawn);
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
let message = self.message.take();
// The guest's own ender inventory; one the host has open goes back
// into the host's while the guest acts.
@@ -359,6 +386,7 @@ impl Game<'_> {
destroy_delay,
using,
use_delay,
shield_cooldown,
attack_ticks,
attack_item,
swing,
@@ -411,6 +439,7 @@ impl Game<'_> {
destroy_delay,
using,
use_delay,
shield_cooldown,
attack_ticks,
attack_item,
swing,
@@ -460,29 +489,46 @@ impl Game<'_> {
let bed = std::mem::replace(&mut self.sleeping, sleeping);
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
(self.respawn_config, self.level.spawn) = respawn;
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
self.level.spawn = respawn.1;
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
let guest_message = std::mem::replace(&mut self.message, message);
self.riding = riding;
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
self.left = left;
self.right = right;
play.mining = std::mem::replace(&mut self.mining, mining);
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
play.using = std::mem::replace(&mut self.using, using);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
play.swing = std::mem::replace(&mut self.swing, swing);
std::mem::swap(&mut self.mount, &mut play.mount);
self.container = container;
self.screen = screen;
self.sign_edit = host_sign;
self.book = host_book;
// A lectern the guest's use opened (LecternBlock.openScreen).
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
Some(super::books::BookScreen::View {
lectern: Some(key), ..
}) => Some(key),
_ => None,
};
self.command_block_screen = host_command_block;
self.mount_menu = host_mount_menu;
// A mount inventory the guest's action opened (shift + use, or the
// inventory key while riding).
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
self.keys = host_keys;
let g = &mut host.hub.guests[i];
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
if guest_vehicle.is_none() {
g.vehicle_good = None;
}
g.vehicle = guest_vehicle;
let fell_asleep = bed.is_some() && g.bed.is_none();
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
g.sleeping = g.bed.is_some();
if let Some((text, _)) = guest_message {
g.send(&C::SystemChat(play::SystemChat {
@@ -532,12 +578,31 @@ impl Game<'_> {
g.health = m.health.health();
g.food = m.health.food.food;
g.saturation = m.health.food.saturation;
// A teleport (commands) moves the guest.
// A teleport (commands, a pearl) moves the guest; a pearl's is
// sent even to where they stand (ServerPlayer.teleport).
let at = [m.position.x, m.position.y, m.position.z];
m.sprinting = false;
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
if moved > 0.0001 || turned {
// Entity.teleportTo takes a rider out of its vehicle.
let left_vehicle = match g.vehicle.filter(|_| teleported) {
Some(v) => {
(g.vehicle, g.vehicle_good) = (None, None);
Some(v)
}
None => left_vehicle,
};
// ServerPlayer.dismountTo: the client hears it is out of the
// vehicle before the teleport beside it. A rider sits where its
// vehicle puts it (lan_vehicles.rs), with no teleport.
if let Some(vehicle) = left_vehicle {
g.send(&C::SetPassengers(play::SetPassengers {
vehicle: vehicle as i32,
passengers: Vec::new(),
}));
}
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
host.hub.teleport(i, Some(at));
}
}
@@ -571,9 +636,15 @@ impl Game<'_> {
if let Some(station) = opened_station {
self.lan_station_opened(host, conn, station);
}
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(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
}
/// `ServerLevel.players()` in turn: the host's own player, then each
@@ -637,6 +708,13 @@ impl Game<'_> {
_ => ("minecraft:furnace", "container.furnace"),
};
(menu, title, Slots::Block(pos))
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
(
"minecraft:crafter_3x3",
"container.crafter",
Slots::Block(pos),
)
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
(
@@ -679,6 +757,7 @@ impl Game<'_> {
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
host.hub.open_container(
conn,
menu,
@@ -877,16 +956,22 @@ impl Game<'_> {
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
if let Some((position, eye)) = self.look_at_entity(host, gi, p.id) {
let _ = (position, eye);
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
.is_some()
{
let swung = self.as_guest(host, conn, |g| {
let swings = g.swings_started;
let _ = g.use_item();
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_boarded(host, conn, before);
}
}
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
S::SetCreativeModeSlot(p) => {
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
@@ -913,6 +998,24 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_mount_click(host, conn, &p) => {}
// ServerboundPlayerCommandPacket OPEN_INVENTORY: the ridden
// mount's inventory (`HasCustomInventoryScreen`).
S::PlayerCommand(p) if p.action == 5 => {
self.as_guest(host, conn, |g| {
g.open_ridden_mount_menu();
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
}
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
@@ -920,7 +1023,11 @@ impl Game<'_> {
self.drop_guest_items(host, gi, vec![c]);
}
}
S::ContainerClose(p) if self.lan_crafting_closed(host, conn, Some(p.container)) => {
S::ContainerClose(p)
if self.lan_crafting_closed(host, conn, Some(p.container))
|| self.lan_station_closed(host, conn, Some(p.container))
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
{
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
@@ -1111,19 +1218,39 @@ impl Game<'_> {
}
}
/// Aim the swapped-in player at an entity; returns its centre and the eye.
/// Aim the swapped-in player at an entity; returns where it aims and the
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
/// the point the client hit (the interact packet's location) when that
/// is on it, else at its centre.
fn look_at_entity(
&mut self,
host: &mut LanHost,
gi: usize,
id: i32,
location: Option<[f64; 3]>,
) -> Option<([f64; 3], [f64; 3])> {
let object = || {
let (lo, hi, origin) = self.object_target(id)?;
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
let Some(l) = location else {
return Some(middle);
};
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
// A little inside the box, so the ray from the eye meets it.
let inside = [0, 1, 2].map(|k| {
let margin = ((hi[k] - lo[k]) / 2.0).min(0.01);
point[k].clamp(lo[k] + margin, hi[k] - margin)
});
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
Some(if on { inside } else { middle })
};
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
None if self.object_target(id).is_some() => object()?,
// A dragon part or an end crystal (game/dragon.rs).
None => {
let b = self.tracked_special_box(id)?;
@@ -1174,7 +1301,7 @@ impl Game<'_> {
return;
}
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
if self.look_at_entity(host, gi, id).is_none() {
if self.look_at_entity(host, gi, id, None).is_none() {
return;
}
self.as_guest(host, conn, |g| {
@@ -1381,17 +1508,43 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn: a server's player comes back in the Overworld,
// where the world spawn is (respawn anchors and beds aside).
// 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
// once the level has loaded around it.
let server = self.options.dedicated.is_some();
let own = host.hub.guests[i]
.respawn_elsewhere
.as_ref()
.map(RespawnConfig::from_saved);
let elsewhere = own.filter(|_| server);
// In the window's world a guest has no level of its own to wait in:
// its bed is resolved at once when its chunk is there, else it
// comes back on the bed.
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let spawn = in_frame_bed.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]) + 0.6,
f64::from(c.pos[2]) + 0.5,
]
});
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let spawn = elsewhere.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
]
});
let back = self.dim;
let away = self.options.dedicated.is_some()
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
if away {
let away = server && host.hub.guests[i].dimension != level.id();
if server {
self.server_trips.retain(|t| !t.is(conn));
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
log::error!("could not open the Overworld: {e}");
return;
}
}
if away && let Err(e) = self.enter_level(Some(host), level) {
log::error!("could not open {}: {e}", level.id());
return;
}
let g = &mut host.hub.guests[i];
g.dead = false;
@@ -1428,8 +1581,31 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.respawn(i, spawn, &ctx);
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
}
if let Some(c) = in_frame_bed
&& self.dim == super::Dim::Overworld
&& self.world.loaded(c.pos[0], c.pos[2])
{
let found = self
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
.flatten();
let world = self
.level
.world_spawn
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
});
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
host.hub.teleport(i, Some(at));
}
if away && let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
@@ -1438,6 +1614,8 @@ impl Game<'_> {
/// Per tick, for every guest: dig, use items, pick up drops, die.
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.lan_stations_tick(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
.hub
@@ -1455,6 +1633,9 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1509,6 +1690,9 @@ impl Game<'_> {
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_orbs(host, gi);
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
@@ -1639,6 +1823,7 @@ impl Game<'_> {
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
self.guest_vehicle_tick(host, conn);
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
@@ -1657,6 +1842,7 @@ 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 (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -1675,6 +1861,26 @@ impl Game<'_> {
let _ = w.fluid_base_tick();
w.player.ground.on_ground = from_ground;
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
// Entity.move's movementThisTick: each move, from where the
// tick's first one started.
let end = w.player.position;
let start = travelled
.iter()
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
w.player.history.clear();
let mut from = start;
for d in &travelled {
let delta = Vec3::new(d[0], d[1], d[2]);
let to = from.add_vector(delta);
w.player
.history
.add(horizon_world::entity_move::MovementRecord {
from,
to,
original: Some(delta),
});
from = to;
}
for (moved, on_ground) in moves {
let movement = Vec3::new(moved[0], moved[1], moved[2]);
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
@@ -1693,6 +1899,17 @@ impl Game<'_> {
break;
}
}
// A ServerPlayer travels on the server too
// (MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER), so every tick
// on a ladder or vine LivingEntity.handleOnClimbable resets its fall
// distance: climbing down doesn't hurt where it lands (#1255).
if w.climbable().unwrap_or(false) {
w.player.fall_distance = 0.0;
}
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.server_block_damage(start);
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
@@ -1702,7 +1919,8 @@ impl Game<'_> {
for d in travelled {
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
}
// LivingEntity.baseTick ends with tickEffects.
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
let Some(player) = game.player.as_mut() else {
return Vec::new();
@@ -1853,6 +2071,23 @@ impl Game<'_> {
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
/// host and the other guests.
/// A player who dies digs and uses nothing any more: the block it was
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
let Some(play) = host.play.get_mut(&conn) else {
return;
};
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
play.mining = None;
play.delayed = None;
play.destroy_delay = 0;
play.using = None;
play.last_progress = -1;
if let Some((pos, _)) = cracked {
self.guest_crack_gone(host, conn, pos);
}
}
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
return;
@@ -83,17 +83,16 @@ impl Game<'_> {
/// `LivingEntity.removeFrost`), what finalizeSpawn set, the baby speed
/// modifier, its effects' modifiers, its equipment's, and what
/// `/attribute` changed.
pub(super) fn mob_attributes(&self, m: &Mob) -> Vec<AttributeSnapshot> {
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
let kind = m.kind.id.as_str();
let mut out = Vec::new();
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
instance(&mut out, a, finalize::base_value(m, Attr::MovementSpeed));
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
}
if let Some(s) = m.extra.attributes.as_deref() {
for (attr, v) in &s.bases {
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
{
instance(&mut out, a, *v).base = *v;
instance(out, a, *v).base = *v;
}
}
for x in &s.modifiers {
@@ -101,7 +100,7 @@ impl Game<'_> {
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
{
add_modifier(
&mut out,
out,
a,
finalize::base_value(m, x.attr),
AttributeModifier {
@@ -122,7 +121,7 @@ impl Game<'_> {
finalize::transient(m, Attr::MovementSpeed),
) {
add_modifier(
&mut out,
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
AttributeModifier {
@@ -150,7 +149,7 @@ impl Game<'_> {
};
let start = base(attribute).unwrap_or_else(|| self.attribute_default(kind, attribute));
add_modifier(
&mut out,
out,
a,
start,
AttributeModifier {
@@ -172,10 +171,9 @@ impl Game<'_> {
("chest", e.chest.as_ref()),
("head", e.head.as_ref()),
],
&mut out,
out,
);
self.generic_attributes_of(Target::Mob(m.id), &mut out);
out
self.generic_attributes_of(Target::Mob(m.id), out);
}
/// An armor stand's synced instances: movement speed (its first
+99 -11
View File
@@ -55,6 +55,7 @@ fn load_player(menu: &mut Menu, g: &Guest) {
menu.set_player_item(OFFHAND, g.offhand.clone());
menu.selected = g.selected;
menu.creative = g.creative();
menu.levels = g.xp_level;
menu.carried = g.carried.clone();
}
@@ -83,27 +84,47 @@ fn place_back(menu: &mut Menu, env: &Env, stacks: Vec<ItemStack>) {
}
impl Game<'_> {
fn menu_env(&self) -> Env<'_> {
pub(super) fn menu_env(&self) -> Env<'_> {
Env {
items: self.res.items(),
recipes: self.res.recipes(),
brewing: &self.res.stations().brewing,
banners: &self.res.stations().banners,
station_recipes: &self.res.stations().recipes,
enchantments: &self.res.stations().enchantments,
names: &self.pack.names,
trade: None,
maps: None,
}
}
/// Runs `op` on the guest's menu with their inventory copied in and back.
fn guest_menu_op<R>(
pub(super) fn guest_menu_op<R>(
&mut self,
host: &mut LanHost,
gi: usize,
menu: &mut Menu,
op: impl FnOnce(&mut Menu, &Env) -> R,
) -> R {
let env = self.menu_env();
// The cartography table reads and makes the host level's maps.
let maps = (menu.kind() == Kind::Cartography).then(|| {
let g = &host.hub.guests[gi];
let held: Vec<ItemStack> = (0..menu.slot_count())
.filter_map(|s| menu.slot(s).cloned())
.chain(g.inventory.iter().flatten().cloned())
.chain(g.carried.clone())
.collect();
self.level_maps(&held)
});
let env = Env {
maps: maps.as_ref().map(|m| m as &dyn horizon_client::menu::Maps),
..self.menu_env()
};
load_player(menu, &host.hub.guests[gi]);
let result = op(menu, &env);
if let Some(maps) = maps {
self.register_made_maps(maps);
}
let dropped = std::mem::take(&mut menu.dropped);
let equipped = std::mem::take(&mut menu.equipped);
menu.drops.clear();
@@ -201,8 +222,14 @@ impl Game<'_> {
(Kind::Furnace(recipe), f.items.to_vec())
}
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
_ => return false,
};
// 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)),
_ => None,
};
if host.hub.guests[gi].spectator() {
return true;
}
@@ -219,6 +246,13 @@ impl Game<'_> {
for (k, s) in items.iter().enumerate() {
menu.set_slot(env, k, s.clone());
}
// After the slots: setting a disabled slot (CrafterBlockEntity.setItem) enables it.
if let Some((disabled, triggered)) = crafter_data {
for (k, d) in disabled.iter().enumerate() {
menu.data[k] = i32::from(*d);
}
menu.data[9] = i32::from(triggered);
}
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
@@ -241,6 +275,13 @@ impl Game<'_> {
b.items[k] = menu.slot(k).cloned();
}
}
// CrafterBlockEntity.setItem: filling a disabled slot enables it.
Some(BlockEntity::Crafter(c)) => {
for k in 0..9 {
c.items[k] = menu.slot(k).cloned();
c.disabled[k] = menu.data[k] == 1;
}
}
_ => {}
}
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
@@ -248,6 +289,39 @@ impl Game<'_> {
true
}
/// `ServerGamePacketListenerImpl.handleContainerSlotStateChanged`: a
/// guest's crafter slot toggle (`CrafterBlockEntity.setSlotState`) in
/// their open crafter window.
pub(super) fn lan_crafter_slot_state(
&mut self,
host: &mut LanHost,
conn: u32,
window: i32,
slot: i32,
enabled: bool,
) {
let Some(pos) = host
.hub
.guest(conn)
.filter(|g| !g.spectator())
.and_then(|g| g.menu.as_ref())
.filter(|m| window != 0 && m.window == window)
.and_then(|m| match m.slots {
Slots::Block(pos) => Some(pos),
_ => None,
})
else {
return;
};
if let (Some(BlockEntity::Crafter(c)), Ok(slot)) = (
self.world.block_entities.get_mut(&pos),
usize::try_from(slot),
) {
c.set_slot_state(slot, enabled);
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
}
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
/// book places a known recipe in their inventory grid or crafting table
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
@@ -331,6 +405,7 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
@@ -430,22 +505,35 @@ impl Game<'_> {
None => return false,
}
};
if grid.is_empty() {
return true;
if !grid.is_empty() {
self.lan_place_back(host, gi, grid);
}
let mut menu = Menu::new(Kind::Inventory);
self.guest_menu_op(host, gi, &mut menu, |menu, env| place_back(menu, env, grid));
true
}
/// A guest left: what their crafting grids held goes back into their
/// `Inventory.placeItemBackInInventory` of `stacks` for guest `gi`, what
/// doesn't fit dropped (`clearContainer`).
pub(super) fn lan_place_back(&mut self, host: &mut LanHost, gi: usize, stacks: Vec<ItemStack>) {
let mut menu = Menu::new(Kind::Inventory);
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
place_back(menu, env, stacks)
});
}
/// A guest left: what their crafting grids and workstation held goes back into their
/// inventory before it is saved (dropped where they stood if full).
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
let table = host.play.remove(&g.conn).and_then(|p| p.crafting.table);
let play = host.play.remove(&g.conn);
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
g.craft_result = None;
if let Some((_, _, menu)) = table {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
if let Some(play) = play {
if let Some((_, _, menu)) = play.crafting.table {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
if let Some(station) = &play.station {
grid.extend(super::lan_stations::station_inputs(station));
}
}
if grid.is_empty() {
return;
+43 -9
View File
@@ -5,7 +5,7 @@
//! synched data that differs from the defaults), and who rides what.
use super::Game;
use horizon_client::lan::{
entity::{EntityState, Riding},
entity::{EntityState, Riding, States},
hub::Hub,
};
use horizon_protocol::entity_data::{Value, serializer};
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
velocity: [0.0; 3],
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
}
}
@@ -89,8 +92,17 @@ fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
impl Game<'_> {
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
/// host in its boat or minecart.
pub(super) fn lan_riding(&self, host_entity: Option<i32>, riding: &mut Riding) {
pub(super) fn lan_riding(
&self,
host_entity: Option<i32>,
guests: &[(u32, i32)],
riding: &mut Riding,
) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
riding.add(vehicle as i32, guest);
}
for m in self.mobs.iter().filter(|m| !m.removed) {
if let Some(vehicle) = m.extra.mount {
riding.add(vehicle as i32, m.id as i32);
@@ -107,7 +119,7 @@ impl Game<'_> {
&self,
host_entity: Option<i32>,
hub: &mut Hub,
out: &mut Vec<EntityState>,
out: &mut States,
) {
for b in &self.boats {
let p = b.position;
@@ -264,22 +276,24 @@ impl Game<'_> {
self.tridents.iter_mut().for_each(|t| give(&mut t.id));
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
self.potions.thrown_ids().for_each(&mut give);
self.end.eye_ids().for_each(&mut give);
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
/// end crystals, as guests track them.
fn lan_projectile_states(
&self,
host_entity: Option<i32>,
hub: &mut Hub,
out: &mut Vec<EntityState>,
) {
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
let owner = |who: Option<horizon_client::mobs::Who>| match who {
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
}
for t in self.tnts.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:tnt", at(t.position), 0.0, t.on_ground);
e.velocity = at(t.velocity);
@@ -347,6 +361,26 @@ impl Game<'_> {
}
out.push(e);
}
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
// (the potion's colour), which a plain bottle's type already has.
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
let mut e = state(id, kind, at(position), 0.0, false);
e.velocity = at(velocity);
if stack.extra.is_some() || stack.count != 1 {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(stack.clone()))),
));
}
out.push(e);
}
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
for (id, position, velocity) in self.end.eyes_tracked() {
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
e.velocity = velocity;
out.push(e);
}
for t in self.tridents.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
@@ -0,0 +1,222 @@
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
//! (`Kind::Mount`) with the mount's saddle, body item and chest copied in and
//! written back, and the slots go to the client after each change, as
//! `broadcastChanges` does.
use super::{Game, lan::LanHost, lan_crafting::input};
use horizon_client::{
items::ItemStack,
menu::{Kind, Menu},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
sent: Option<(Vec<Option<ItemStack>>, Option<ItemStack>)>,
}
impl Game<'_> {
/// `ServerPlayer.openHorseInventory` for mount `mob`: the open menu
/// closes, the client gets the mount screen and the menu's slots.
pub(super) fn lan_open_mount(&mut self, host: &mut LanHost, conn: u32, mob: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
host.play.entry(conn).or_default().mount_window = Some(GuestMount {
window,
mob,
columns,
state: 0,
sent: None,
});
self.lan_send_mount(host, conn);
}
/// `HorseInventoryMenu` over mount `index`, with the player's inventory
/// loaded by the caller.
fn mount_engine_menu(&self, index: usize, columns: usize) -> Menu {
let env = self.menu_env();
let mut menu = Menu::new(Kind::Mount(columns));
menu.mount = Some(self.mobs[index].kind.id.to_string());
let (saddle, body, items) = self.mount_contents(index);
menu.set_slot(&env, 0, saddle);
menu.set_slot(&env, 1, body.flatten());
for (k, item) in items.into_iter().take(columns * 3).enumerate() {
menu.set_slot(&env, 2 + k, item);
}
menu
}
/// The menu's slots (the mount's, then the inventory and the hotbar)
/// and the carried stack, sent when they changed since the last time.
fn lan_send_mount(&mut self, host: &mut LanHost, conn: u32) {
let Some(gm) = host.play.get(&conn).and_then(|p| p.mount_window.as_ref()) else {
return;
};
let (mob, columns) = (gm.mob, gm.columns);
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
return;
};
let (saddle, body, items) = self.mount_contents(index);
let mut slots = vec![saddle, body.flatten()];
slots.extend(items.into_iter().take(columns * 3));
slots.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
let carried = g.carried.clone();
let now = Some((slots, carried));
let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|p| p.mount_window.as_mut())
else {
return;
};
if gm.sent == now {
return;
}
gm.state = (gm.state + 1) & 0x7FFF;
let (window, state) = (gm.window, gm.state);
let (slots, carried) = now.clone().unwrap_or_default();
gm.sent = now;
let content = C::ContainerSetContent(play::ContainerSetContent {
container: window,
state,
items: slots.iter().map(|s| host.hub.stack(s)).collect(),
carried: host.hub.stack(&carried),
});
host.hub.send_to(conn, &content);
}
/// A click on the guest's mount window; false for other windows.
pub(super) fn lan_mount_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
let Some((mob, columns)) = host
.play
.get(&conn)
.and_then(|pl| pl.mount_window.as_ref())
.filter(|m| m.window == p.container)
.map(|m| (m.mob, m.columns))
else {
return false;
};
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& let Some(index) = self.mobs.iter().position(|m| m.id == mob)
{
let mut menu = self.mount_engine_menu(index, columns);
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));
});
self.lan_mount_store(index, columns, &menu);
}
// The client is told what the server has, whatever it predicted.
if let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|pl| pl.mount_window.as_mut())
{
gm.sent = None;
}
self.lan_send_mount(host, conn);
true
}
/// The engine menu's mount slots back on mount `index`.
fn lan_store_slots(menu: &Menu, columns: usize) -> Vec<Option<ItemStack>> {
(0..columns * 3)
.map(|k| menu.slot(2 + k).cloned())
.collect()
}
fn lan_mount_store(&mut self, index: usize, columns: usize, menu: &Menu) {
let (_, body, _) = self.mount_contents(index);
let body = body.map(|_| menu.slot(1).cloned());
let items = Self::lan_store_slots(menu, columns);
self.mount_store(index, menu.slot(0).cloned(), body, items);
}
/// `AbstractContainerMenu.removed` of the guest's mount menu (`window`;
/// None closes it whichever it is): the carried stack goes back to the
/// inventory. False when no such window is open.
pub(super) fn lan_mount_closed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
let closed = host
.play
.get_mut(&conn)
.and_then(|p| {
p.mount_window
.take_if(|m| window.is_none_or(|w| m.window == w))
})
.is_some();
if closed
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& let Some(carried) = host.hub.guests[gi].carried.take()
{
self.lan_place_back(host, gi, vec![carried]);
}
closed
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
.iter()
.filter_map(|(c, p)| p.mount_window.as_ref().map(|m| (*c, m.mob, m.window)))
.collect();
for (conn, mob, window) in open {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
}
}
}
}
@@ -0,0 +1,672 @@
//! Workstation menus for LAN guests: the screen a guest's use opens runs on
//! the host's menu engine (`horizon_client::menu`), the one single player
//! uses, with vanilla's packets: OpenScreen with the station's `MenuType`,
//! container clicks, `ServerboundContainerButtonClickPacket`, the content and
//! data slot sync after each (`broadcastChanges`), and the inputs back into
//! the inventory when the menu closes (`AbstractContainerMenu.clearContainer`).
use super::{
Game,
lan::LanHost,
lan_crafting::input,
stations::{Kind as StationKind, Station},
};
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
menu::{Kind, Menu},
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open workstation menu.
pub(super) struct GuestStation {
window: i32,
state: i32,
kind: StationKind,
pos: [i32; 3],
menu: Box<Menu>,
/// The data slots the client was last sent.
sent_data: Vec<i32>,
/// The game time of the menu's last sound (`lastSoundTime`).
last_sound: u64,
}
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
/// menu engine for each click (`Game::lectern_menu`).
pub(super) struct GuestLectern {
window: i32,
state: i32,
key: (i32, i32, i32),
/// The book and page the client was last sent (`broadcastChanges`).
sent: Option<(Option<ItemStack>, i32)>,
}
/// The `MenuType`, the engine's menu and the input slots
/// (`clearContainer`'s container) of a station a guest can use.
fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
Some(match kind {
StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3),
StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1),
StationKind::Grindstone => ("minecraft:grindstone", Kind::Grindstone, 0..2),
StationKind::Anvil => ("minecraft:anvil", Kind::Anvil, 0..2),
StationKind::Smithing => ("minecraft:smithing", Kind::Smithing, 0..3),
StationKind::Enchanting => ("minecraft:enchantment", Kind::Enchanting, 0..2),
StationKind::Beacon => ("minecraft:beacon", Kind::Beacon, 0..1),
StationKind::Cartography => ("minecraft:cartography_table", Kind::Cartography, 0..2),
_ => return None,
})
}
impl Game<'_> {
/// A workstation a guest's use opened (`ServerPlayer.openMenu`); false
/// when guests can't use that one yet.
pub(super) fn lan_open_station(
&mut self,
host: &mut LanHost,
conn: u32,
station: &Station,
) -> bool {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
return true;
};
let mut menu = Box::new(Menu::new(kind));
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& kind == Kind::Enchanting
{
// Player.enchantmentSeed, made when first needed.
if g.xp_seed == 0 {
g.xp_seed = self.world.random.next_int();
}
menu.data[3] = g.xp_seed;
}
if kind == Kind::Beacon {
menu.data = self.beacon_data(host, station.pos).to_vec();
}
host.play.entry(conn).or_default().station = Some(GuestStation {
window,
state: 0,
kind: station.kind,
pos: station.pos,
menu,
sent_data: Vec::new(),
last_sound: 0,
});
self.lan_send_station(host, conn);
true
}
/// Runs `op` on the guest's open station (window `window`, any when
/// None) with their inventory in it, then sends what changed. False when
/// no such window is open.
fn lan_station_op(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
) -> bool {
let Some(mut gs) = host
.play
.get_mut(&conn)
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
else {
return false;
};
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
{
self.guest_menu_op(host, gi, &mut gs.menu, op);
self.lan_station_sounds(&mut gs);
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
}
self.lan_station_combined(host, conn, &mut gs);
if let Some(button) = gs.menu.enchant_button.take() {
self.lan_enchant(host, gi, &mut gs, button);
}
if std::mem::take(&mut gs.menu.enchant_changed) {
self.lan_enchant_offers(host, gi, &mut gs);
}
}
host.play.entry(conn).or_default().station = Some(gs);
self.lan_send_station(host, conn);
true
}
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
return;
};
host.play.entry(conn).or_default().lectern = Some(GuestLectern {
window,
state: 0,
key,
sent: None,
});
self.lan_send_lectern(host, conn, true);
}
/// The lectern's book and page for the client: everything (`all`), or
/// what changed since it was last sent.
fn lan_send_lectern(&mut self, host: &mut LanHost, conn: u32, all: bool) {
let Some(gl) = host.play.get_mut(&conn).and_then(|p| p.lectern.as_mut()) else {
return;
};
let (book, page) = match self.world.block_entities.get(&gl.key) {
Some(BlockEntity::Lectern(l)) => (l.book.clone(), l.page),
_ => (None, 0),
};
let now = Some((book.clone(), page));
if !all && gl.sent == now {
return;
}
let page_changed = gl.sent.as_ref().is_none_or(|(_, p)| *p != page);
gl.sent = now;
gl.state = (gl.state + 1) & 0x7FFF;
let (window, state) = (gl.window, gl.state);
let carried = host
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.and_then(|g| g.carried.clone());
let content = C::ContainerSetContent(play::ContainerSetContent {
container: window,
state,
items: vec![host.hub.stack(&book)],
carried: host.hub.stack(&carried),
});
host.hub.send_to(conn, &content);
if all || page_changed {
host.hub.send_to(
conn,
&C::ContainerSetData(play::ContainerSetData {
container: window,
id: 0,
value: page as i16,
}),
);
}
}
/// Runs `op` on the guest's lectern menu (window `window`) with their
/// inventory, the block entity takes the book and page back, and the
/// client gets what changed. False when no such window is open.
fn lan_lectern_op(
&mut self,
host: &mut LanHost,
conn: u32,
window: i32,
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
) -> bool {
let Some(key) = host
.play
.get(&conn)
.and_then(|p| p.lectern.as_ref())
.filter(|l| l.window == window)
.map(|l| l.key)
else {
return false;
};
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& let Some(mut menu) = self.lectern_menu(key)
{
// Player.mayBuild: survival and creative.
let may_build = matches!(host.hub.guests[gi].game_mode, 0 | 1);
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.may_build = may_build;
op(menu, env);
});
self.lectern_menu_done(key, &menu);
}
self.lan_send_lectern(host, conn, true);
true
}
/// A click on the guest's lectern window; false for other windows.
pub(super) fn lan_lectern_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
self.lan_lectern_op(host, conn, p.container, |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
})
}
/// The rest of a taken anvil or smithing result's `onTake`: the guest
/// pays the levels, the anvil wears (level event 1030 or 1029), the
/// smithing table sounds (1044).
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
let change = std::mem::take(&mut gs.menu.level_change);
if change != 0 {
self.as_guest(host, conn, |g| g.give_levels(change));
}
if !std::mem::take(&mut gs.menu.combined) {
return;
}
if gs.kind == StationKind::Smithing {
self.level_event_at(1044, gs.pos, 0);
} else {
let creative = gs.menu.creative;
// A broken anvil closes the menu on the next stillValid check.
self.anvil_used(gs.pos, creative);
}
}
/// `EnchantmentMenu.slotsChanged` with a level: the costs and clues for
/// the guest's seed and the bookshelves around the table.
fn lan_enchant_offers(&mut self, host: &LanHost, gi: usize, gs: &mut GuestStation) {
let seed = host.hub.guests[gi].xp_seed;
let bookcases = self.count_bookshelves(gs.pos);
let offers = horizon_client::stations::enchant_offers(
&self.res.stations().enchantments,
self.res.items(),
seed,
bookcases,
gs.menu.slot(0),
);
for i in 0..3 {
gs.menu.data[i] = offers.costs[i];
let clue = offers.clues[i].as_ref();
gs.menu.data[4 + i] = clue
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
.unwrap_or(-1);
gs.menu.data[7 + i] = clue.map_or(-1, |(_, l)| *l as i32);
}
gs.menu.data[3] = seed;
}
/// `EnchantmentMenu.clickMenuButton`'s `access.execute`: the item gets
/// the offer's enchantments, the guest pays its levels and lapis and
/// gets a new seed, the table sounds, and the offers are made again.
fn lan_enchant(&mut self, host: &mut LanHost, gi: usize, gs: &mut GuestStation, button: usize) {
let Some(item) = gs.menu.slot(0).cloned() else {
return;
};
let g = &host.hub.guests[gi];
let (seed, creative, conn) = (g.xp_seed, g.creative(), g.conn);
let Some(enchanted) = horizon_client::stations::apply_enchantment(
&self.res.stations().enchantments,
self.res.items(),
seed,
button,
gs.menu.data[button],
&item,
) else {
return;
};
let need = button as i32 + 1;
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
});
host.hub.guests[gi].xp_seed = self.world.random.next_int();
let env = self.menu_env();
let lapis = gs.menu.slot(1).cloned().filter(|_| !creative).map(|mut l| {
l.count = l.count.saturating_sub(need as u32);
l
});
gs.menu.set_slot(&env, 0, Some(enchanted));
if !creative {
gs.menu.set_slot(&env, 1, lapis.filter(|l| l.count > 0));
}
gs.menu.enchant_changed = false;
self.lan_enchant_offers(host, gi, gs);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
let at = gs.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(
"minecraft:block.enchantment_table.use",
Some(at),
1.0,
pitch,
);
}
/// `ServerGamePacketListenerImpl.handleRenameItem`: the guest's anvil
/// takes the name (`AnvilMenu.setItemName`).
pub(super) fn lan_station_rename(&mut self, host: &mut LanHost, conn: u32, name: &str) {
let anvil = host
.play
.get(&conn)
.and_then(|p| p.station.as_ref())
.is_some_and(|s| s.kind == StationKind::Anvil);
if anvil {
self.lan_station_op(host, conn, None, |menu, env| {
menu.rename(env, name);
});
}
}
/// The sounds the menu played, at the station (heard by everyone around,
/// as `level.playSound(null, pos, ...)`), once per tick.
fn lan_station_sounds(&mut self, gs: &mut GuestStation) {
let sounds = std::mem::take(&mut gs.menu.sounds);
if sounds.is_empty() || gs.last_sound == self.ticks {
return;
}
gs.last_sound = self.ticks;
let at = gs.pos.map(|v| f64::from(v) + 0.5);
for sound in sounds {
self.audio.play(sound, Some(at), 1.0, 1.0);
}
}
/// A click on the guest's station window; false for other windows.
pub(super) fn lan_station_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
self.lan_station_op(host, conn, Some(p.container), |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
})
}
/// `ServerGamePacketListenerImpl.handleContainerButtonClick`:
/// `clickMenuButton`, then `broadcastChanges` when the menu took it.
pub(super) fn lan_station_button(
&mut self,
host: &mut LanHost,
conn: u32,
window: i32,
id: i32,
) {
// LecternMenu's page buttons and Take Book.
if self.lan_lectern_op(host, conn, window, |menu, env| {
menu.button(env, id);
}) {
return;
}
self.lan_station_op(host, conn, Some(window), |menu, env| {
menu.button(env, id);
});
}
/// The whole station menu and its changed data slots for the client
/// (`sendAllDataToRemote` / `broadcastChanges`).
fn lan_send_station(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut()) else {
return;
};
gs.state = (gs.state + 1) & 0x7FFF;
let own = gs.menu.slot_count() - 36;
let mut items: Vec<Option<ItemStack>> =
(0..own).map(|s| gs.menu.slot(s).cloned()).collect();
let g = &host.hub.guests[gi];
items.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
let carried = g.carried.clone();
let packet = C::ContainerSetContent(play::ContainerSetContent {
container: gs.window,
state: gs.state,
items: items.iter().map(|s| host.hub.stack(s)).collect(),
carried: host.hub.stack(&carried),
});
gs.sent_data.resize(gs.menu.data.len(), i32::MIN);
let mut data = Vec::new();
for (k, &v) in gs.menu.data.iter().enumerate() {
if gs.sent_data[k] != v {
gs.sent_data[k] = v;
data.push(C::ContainerSetData(play::ContainerSetData {
container: gs.window,
id: k as i16,
value: v as i16,
}));
}
}
host.hub.send_to(conn, &packet);
for packet in data {
host.hub.send_to(conn, &packet);
}
}
/// `AbstractContainerMenu.removed` of the guest's station (`window`;
/// None closes it whichever it is): the carried stack and the inputs go
/// back to the inventory, what doesn't fit is dropped. False when no such
/// window is open.
pub(super) fn lan_station_closed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
// A lectern's menu keeps nothing of its own (the book is the block entity's).
let lectern = host
.play
.get_mut(&conn)
.and_then(|p| p.lectern.take_if(|l| window.is_none_or(|w| l.window == w)));
if lectern.is_some() {
// AbstractContainerMenu.removed: the carried stack goes back.
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& let Some(carried) = host.hub.guests[gi].carried.take()
{
self.lan_place_back(host, gi, vec![carried]);
}
if window.is_some() {
return true;
}
}
let Some(gs) = host
.play
.get_mut(&conn)
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
else {
return false;
};
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return true;
};
// AbstractContainerMenu.removed: the carried stack first, then the inputs.
let mut back: Vec<ItemStack> = host.hub.guests[gi].carried.take().into_iter().collect();
let inputs = station_inputs(&gs);
if gs.kind == StationKind::Beacon {
// BeaconMenu.removed drops the payment (player.drop).
if !back.is_empty() {
self.lan_place_back(host, gi, std::mem::take(&mut back));
}
self.drop_guest_items(host, gi, inputs);
} else {
back.extend(inputs);
}
if !back.is_empty() {
self.lan_place_back(host, gi, back);
}
true
}
/// `BeaconBlockEntity.dataAccess`: the pyramid's levels and the powers
/// (`BeaconMenu.encodeEffect`: the `mob_effect` id + 1, 0 for none).
fn beacon_data(&self, host: &LanHost, pos: [i32; 3]) -> [i32; 3] {
let Some(BlockEntity::Beacon(b)) = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return [0; 3];
};
let encode = |e: &Option<String>| {
e.as_deref()
.and_then(|n| host.hub.registry_id("minecraft:mob_effect", n))
.map_or(0, |id| id + 1)
};
[b.levels, encode(&b.primary), encode(&b.secondary)]
}
/// `ServerGamePacketListenerImpl.handleSetBeaconPacket`: the guest's
/// beacon menu takes the powers (`BeaconMenu.updateEffects`), and they
/// become the block entity's (`dataAccess.set`: the power_select sound
/// while the beam is up).
pub(super) fn lan_station_beacon(
&mut self,
host: &mut LanHost,
conn: u32,
primary: Option<i32>,
secondary: Option<i32>,
) {
let Some(pos) = host
.play
.get(&conn)
.and_then(|p| p.station.as_ref())
.filter(|s| s.kind == StationKind::Beacon)
.map(|s| s.pos)
else {
return;
};
// An id the registry doesn't have fails the packet's codec.
let mut powers: [Option<(i32, String)>; 2] = [None, None];
for (k, id) in [primary, secondary].into_iter().enumerate() {
if let Some(id) = id {
let Some(name) = host.hub.registry_name("minecraft:mob_effect", id) else {
return;
};
powers[k] = Some((id, name.to_owned()));
}
}
let data = self.beacon_data(host, pos);
let mut updated = false;
self.lan_station_op(host, conn, None, |menu, env| {
menu.data.copy_from_slice(&data);
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
updated = menu.beacon_update(env, power(0), power(1));
});
if !updated {
return;
}
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
let mut select_sound = false;
if let Some(BlockEntity::Beacon(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.primary = primary;
b.secondary = secondary;
select_sound = !b.sections.is_empty();
}
if select_sound {
self.beacon_sound(pos, "minecraft:block.beacon.power_select");
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `ServerPlayer.doTick`'s `stillValid`: a guest's station closes once its
/// block is gone or the guest is out of reach (`isWithinBlockInteractionRange(pos, 4)`).
pub(super) fn lan_stations_tick(&mut self, host: &mut LanHost) {
self.lan_lecterns_tick(host);
let conns: Vec<u32> = host
.play
.iter()
.filter(|(_, p)| p.station.is_some())
.map(|(c, _)| *c)
.collect();
for conn in conns {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
if !host.hub.here(g) {
continue;
}
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
continue;
};
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);
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
// The block interaction range (4.5, creative 5) and the buffer.
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
let near = (0..3)
.map(|k| {
let lo = f64::from(gs.pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach;
if !(block && near) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
} else if gs.kind == StationKind::Beacon {
// The beacon's data slots follow its block entity.
let data = self.beacon_data(host, gs.pos);
if let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut())
&& gs.menu.data[..] != data[..]
{
gs.menu.data.copy_from_slice(&data);
self.lan_send_station(host, conn);
}
}
}
}
}
impl Game<'_> {
/// The guests' lecterns each tick: `stillValid` (the block entity, its
/// book, the reach) closes them, and `broadcastChanges` sends a book or
/// page another player changed.
fn lan_lecterns_tick(&mut self, host: &mut LanHost) {
let open: Vec<(u32, i32, (i32, i32, i32))> = host
.play
.iter()
.filter_map(|(c, p)| p.lectern.as_ref().map(|l| (*c, l.window, l.key)))
.collect();
for (conn, window, key) in open {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
if !host.hub.here(g) {
continue;
}
let has_book = matches!(
self.world.block_entities.get(&key),
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;
if has_book && near {
self.lan_send_lectern(host, conn, false);
} else {
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
}
}
}
}
/// What `clearContainer` gives back from a station's menu.
pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
menu_of(gs.kind)
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
.unwrap_or_default()
}
+10 -20
View File
@@ -55,8 +55,9 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
// The container the trading screen replaces loses its opener.
// The container or workstation the trading screen replaces is closed.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
self.merchant_closed(&mut state);
return;
@@ -128,23 +129,6 @@ impl Game<'_> {
}
/// The screen title: the custom name, else the profession (the client
/// adds the level itself).
#[allow(dead_code)]
fn merchant_title(&self, state: &MerchantState) -> String {
if let Some(name) = &state.name {
return name.clone();
}
let e = &self.res.stations().enchantments;
let profession = state.session.profession.trim_start_matches("minecraft:");
if state.session.villager {
e.translate(&format!("entity.minecraft.villager.{profession}"))
.unwrap_or(profession)
.to_owned()
} else {
e.translate("entity.minecraft.wandering_trader")
.unwrap_or("Wandering Trader")
.to_owned()
}
}
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
fn lan_send_offers(
&mut self,
@@ -254,7 +238,11 @@ impl Game<'_> {
recipes: self.res.recipes(),
brewing: &self.res.stations().brewing,
banners: &self.res.stations().banners,
station_recipes: &self.res.stations().recipes,
enchantments: &self.res.stations().enchantments,
names: &self.pack.names,
trade: Some(&trade_env),
maps: None,
};
let mut menu = Menu::new(Kind::Merchant);
{
@@ -393,8 +381,8 @@ impl Game<'_> {
});
}
}
/// Takes a station a guest's action opened: a trading screen goes to the
/// guest; other stations are not shown to guests yet.
/// Takes a station a guest's action opened: a trading screen or a
/// workstation menu goes to the guest (game/lan_stations.rs).
pub(super) fn lan_station_opened(
&mut self,
host: &mut LanHost,
@@ -406,6 +394,8 @@ impl Game<'_> {
&& let Some(m) = station.merchant.take()
{
self.lan_open_merchant(host, conn, *m);
return;
}
self.lan_open_station(host, conn, &station);
}
}
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff. Show more