Compare commits

...
Author SHA1 Message Date
Ludovic Marie a7841ccb18 Merge remote-tracking branch 'origin/main' into feat/launcher-java-command 2026-10-28 23:18:24 +01:00
Ludovic Marie c9a103852c Merge pull request #3406 from horizon-rust/feat/launcher-any-version-download
🤖 Agent: launcher-java-1 verified this head (main merged in for #3402's Windows fix): PR checks green (verify, launcher) and the launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40775800425). No CLI change, so docs/launcher-commands.md is unchanged.
2026-10-28 23:12:15 +01:00
Ludovic Marie a15faef940 Merge pull request #2768 from levzzz5154/fix/world-difficulty-survives-reopen
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. save round trip main -> PR -> main -> PR: difficulty Hard/Peaceful/Easy read back at each hop and in level.dat difficulty_settings, server thread and --in-frame. Queued for merge at 35581f52e.
2026-10-28 23:12:12 +01:00
Ludovic Marie 58cd73ee0b Merge pull request #2763 from ASOwnerYT/claude/issue-2746-fix-pr-f0eb69
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. changes test_music_parity.py (music harness PASS in the run). Queued for merge at 985ece811.
2026-10-28 23:12:08 +01:00
Ludovic Marie e2a3e92424 Merge pull request #3011 from feeldev12/feat/integrated-loads-async
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. every server-thread launch of this run (screenshots, save round trips) loaded through the new async start. Queued for merge at 356b69aea.
2026-10-28 23:12:04 +01:00
Ludovic Marie 6f8c7a0e5d Merge pull request #3012 from feeldev12/feat/connect-screen
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: ConnectScreen with the status and a working Cancel back to the multiplayer list (main: flat screen, no Cancel). Queued for merge at 752fc2170.
2026-10-28 23:09:50 +01:00
Ludovic Marie 04cbda1b14 Merge pull request #2752 from iLostName/faster-server-connect
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at de8dd66ac.
2026-10-28 23:09:46 +01:00
Ludovic Marie 2de8bbd84b Merge pull request #2998 from feeldev12/perf/server-loot-preload
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at fdefcfeac.
2026-10-28 23:09:42 +01:00
Ludovic Marie a7177cfbd7 Merge pull request #3228 from iLostName/own-death-sound
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. the server sends no death sound to the dying guest, so it is not doubled. Queued for merge at 7b93b310a.
2026-10-28 23:09:38 +01:00
Ludovic Marie b558a845a0 Merge pull request #3066 from iLostName/item-pickup-one-sound
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. pickup sound pitch matches handleTakeItemEntity. Queued for merge at 0112a6fea.
2026-10-28 23:09:33 +01:00
Ludovic Marie 9f1a998403 Merge pull request #2833 from levzzz5154/fix/empty-compressed-packet
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. zero-length frame rejected as Varint21FrameDecoder does. Queued for merge at 65bca4ce9.
2026-10-28 23:07:10 +01:00
Ludovic Marie 527cd7225c Merge pull request #2816 from sirrayi/fix/malformed-network-input
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 2812cf201.
2026-10-28 23:07:06 +01:00
Ludovic Marie f64fdeaa75 Merge pull request #2813 from sirrayi/fix/guest-block-reach
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. reach checks match ServerGamePacketListenerImpl.handleUseItemOn/handleBlockBreakAction; digging/mining/instant_digging lan_e2e PASS. Queued for merge at 3be828d01.
2026-10-28 23:07:01 +01:00
Ludovic Marie a0c79c8b26 Merge pull request #2805 from ASOwnerYT/fix/2775-jukebox-offhand-eject
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. jukebox use read against JukeboxBlock.useItemOn/useWithoutItem. Queued for merge at b38fe43b0.
2026-10-28 23:06:57 +01:00
Ludovic Marie 0284d3bb16 Merge pull request #2952 from kacpherek/fix/lan-block-entity-data
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: the single-player window (server thread) now shows banner patterns and the vault's display item (main: plain banner, empty vault). Follow-up: the trial spawner's mob still does not show on the server-thread window (it does in-frame), not a regression. Queued for merge at 1deb3bd36.
2026-10-28 23:06:54 +01:00
Ludovic Marie 72b4d8ecbb Merge pull request #3395 from horizon-rust/feat/launcher-java-instances
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher) and the launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40775337700); docs/launcher-commands.md is regenerated.
2026-10-28 23:02:35 +01:00
Ludovic Marie 1ed31e54eb Merge pull request #3062 from kacpherek/fix/shield-arm-and-banner
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. screenshot: patterned shields in the hotbar and offhand on the single-player window (main: plain). Queued for merge at 99008baad.
2026-10-28 23:00:42 +01:00
Ludovic Marie b3aaa04129 Merge pull request #3055 from kacpherek/fix/horse-feeding-particles-on-server
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. horse/llama/camel foods match handleEating (no isAgeLocked check: the client has no synced flag). Queued for merge at 9d32bebad.
2026-10-28 23:00:37 +01:00
Ludovic Marie bb38695695 Merge pull request #3052 from kacpherek/fix/hand-drops-after-use-on-server
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at e24394ca3.
2026-10-28 23:00:33 +01:00
Ludovic Marie 6bda112225 Merge pull request #3050 from kacpherek/fix/disconnect-no-wait
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at a5ee1e588.
2026-10-28 23:00:29 +01:00
Ludovic Marie 77fa9fb89f Merge pull request #3388 from horizon-rust/fix/native-mods-mixins-cargo
🤖 Agent: verify green; launcher-only, one function. pr_ready green (launcher tests 228/228). Parity --changed: nothing selected (lan_integration is nightly-only, and the launcher is not in the LAN suite). Proof end to end with no system Rust: the launcher's toolchain built zoom on macOS (546 s) and Linux (729 s, launcher from this branch's dry-run 40767814775), and zoom's offscreen proof passed on both launcher-built games. Before the fix both stopped at E_METADATA.
2026-10-28 22:58:12 +01:00
Ludovic Marie 153d18a102 Merge pull request #3346 from horizon-rust/feat/native-mod-settings
🤖 Agent: verify green. pr_ready on the Mac mini (vanilla data): fmt, clippy, horizon-client 1264 tests, Python 401, parity 75 affected, 74 passed, none failed. Earlier offscreen proofs: the four native mods listed ON with working settings pages. New on/off commit tested by mod_list::tests::toggles_installed_and_native_mods_but_not_dev_ones; horizon-mods#22's mods build on this head with native_on (check all ok).
2026-10-28 22:55:57 +01:00
Ludovic Marie e470f3a0c9 Merge remote-tracking branch 'origin/main' into feat/launcher-any-version-download 2026-10-28 22:53:45 +01:00
Ludovic Marie f93c28a230 Merge pull request #2932 from RVZO6/fix/dig-down-jitter
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 32a0a11fe.
2026-10-28 22:53:41 +01:00
Ludovic Marie b9721d816d Merge pull request #2809 from socksprox/fix/1528-portal-arrival
🤖 Agent: needs-proof check by Ludovic's AI agent (Claude), not by Ludovic himself. Passed on a union of 19 contributor PRs: clippy -D warnings, the touched crates' tests (1391 passed), 80/80 parity harnesses, and lan_e2e with the host on its server thread and in-frame. Queued for merge at 9fcfdaa1e.
2026-10-28 22:53:37 +01:00
Ludovic Marie 2034db1df6 Merge remote-tracking branch 'origin/main' into feat/launcher-java-instances 2026-10-28 22:50:15 +01:00
Ludovic MarieandClaude Opus 5.5 5091896e2a Launcher: the real game's command from its version JSON
java_launch builds the command that starts Minecraft: Java Edition as
the official launcher reads a version file: mainClass; the classpath of
this system's libraries (rules with os name and arch, duplicates once)
and the client jar; arguments.jvm / arguments.game with their rules
(every optional feature off) and ${...} placeholders, or the old
minecraftArguments line with -Djava.library.path and -cp; an old
version's natives unpacked without extract.exclude; virtual and
map_to_resources asset indexes laid out by name; Mojang's logging setup
(-Dlog4j.configurationFile) plus -Dlog4j2.formatMsgNoLookups=true;
-Xmx from the instance and the player's JVM arguments.

Nothing reaches the network; the next PR wires it to Play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:50:02 +01:00
Ludovic Marie 6edc4e950d Merge pull request #3402 from horizon-rust/fix/launcher-java-test-windows
🤖 Agent: verified: launcher build matrix dry run 40773579704 green on Windows, macOS and Linux (java::tests::mojangs_runtimes_are_found_in_their_layout passes on Windows); PR checks green. Test-only fix for main's Windows build after #3386.
2026-10-28 22:49:19 +01:00
Ludovic MarieandClaude Opus 5.5 d4120a9ef0 Launcher: download the version's logging setup with it (Log4Shell fix for 1.7-1.18)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:45:58 +01:00
Ludovic MarieandClaude Opus 5.5 795a0db7e4 Launcher: download any Minecraft version for an owner, natives included
The owner-gated download learns versions Horizon doesn't pin, for Java
instances: Pins::for_version(id, sha1) takes a version from Mojang's
list; its version file is checked against that sha1, and the client's
sha1 then comes from the checked file. backend::download_version_for_owner
puts it in Horizon's own game folder through the same path as the
pinned one (download_for_owner asks the strict gate again, then
download_for → mojang::download), so the one download call site and
its gate test hold.

Versions before 1.19 keep a library's natives as a classifier of their
own (natives map, ${arch} for Windows): mojang::download now fetches
this system's, checked like every other file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 75b8a176d4 Launcher tests: Mojang runtime fixture with Windows' java.exe
java::tests::mojangs_runtimes_are_found_in_their_layout failed on
Windows (main after #3386): the fixture's homes had only bin/javaw.exe
there, and the runtime search (horizon_prepare::java::minecraft_runtimes)
looks for bin/java.exe, so it found nothing. Mojang's Windows runtimes
ship both, so the fixture now does too. The platform folder comes from
mojang::java_platform, the name the download lays out, instead of a
hard-coded mac-os-arm64.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:35:49 +01:00
Ludovic MarieandClaude Opus 5.5 21a0656530 docs: launcher command reference for the Java instance commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:32:11 +01:00
Ludovic Marie 071580e716 Merge pull request #3400 from horizon-rust/launcher-commands-doc
🤖 Agent: docs-only fix for the launcher reference test (java list/install sections); all checks green at this head.
2026-10-28 22:30:19 +01:00
Ludovic Marie ae01d872fb Merge remote-tracking branch 'origin/main' into feat/launcher-java-instances 2026-10-28 22:26:47 +01:00
Ludovic Marie d2f5a0d678 Merge pull request #3398 from horizon-rust/tests-ignore-port-probe
🤖 Agent: tests ignore T3's GET / port probe; account::tests 17/17 on the Mac mini (the_whole_chain failed before); merger waits for parity (online_mode).
2026-10-28 22:26:12 +01:00
Ludovic MarieandClaude Opus 5.5 d6ee462614 docs: the launcher reference lists the java commands
#3386 added `horizon-launcher java list|install` without regenerating
docs/launcher-commands.md, so the_launcher_reference_is_up_to_date failed on main's
launcher builds (and would stop the next launcher release).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:25:41 +01:00
Ludovic Marie 430e5e4c92 Merge pull request #3352 from horizon-rust/feat/native-mods-carry-settings
🤖 Agent: verify green. pr_ready: all green except the launcher test account::tests::the_whole_chain, which fails on this Mac only (a desktop browser's extra GET / on the device-code page); it passes in CI's launcher job. Local parity of this tree (Mac mini, --changed origin/main): 36/37, online_mode fails identically on main here (environment). Test the_native_mods_instance_gets_the_mods_settings_once passes.
2026-10-28 22:22:02 +01:00
Ludovic Marie f3d8535fcd Merge pull request #3348 from horizon-rust/ci/train-builds-nightlies
The batch train builds Nightlies: no more vX.Y.Z game releases
2026-10-28 22:15:22 +01:00
Ludovic MarieandClaude Opus 5.5 559ecb02f6 tests: ignore the bare GET / a local tool sends to every new port
T3 Code probes each new localhost listener with GET / (seen on the Mac mini: the t3
process). The online_mode harness and account::tests::the_whole_chain recorded it as an
unexpected call and failed there only. Neither fake service has a root, so it is ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:11:32 +01:00
Ludovic Marie 11b8757bc7 Merge pull request #3386 from horizon-rust/feat/launcher-java-runtimes
🤖 Agent: verified: pr_ready --quick green on the Mac mini (233 launcher tests), CI launcher/verify/parity-scope green; java install 21 checked end to end in scratch homes (Mojang's runtime runs, real folders untouched).
2026-10-28 22:07:50 +01:00
Ludovic MarieandClaude Opus 5.5 b44da42ca4 Launcher: Minecraft: Java Edition instances (model, versions, CLI)
A Java instance is an own instance whose instances.json entry carries a
`java` section: the Minecraft version it plays and, optionally, its own
Java home, memory and JVM arguments. It gets an empty game folder (the
real game keeps its own options); a copy keeps the section.

- minecraft_versions: Mojang's public version_manifest_v2 (releases,
  snapshots, old betas and alphas, latest), kept in the data folder so
  the list shows offline. Only metadata: game files stay owner-gated.
- Play refuses a Java instance with a clear sentence (resolve_version
  and instances::apply, so the window, the CLI and MCP all say it) until
  the launcher starts the real game.
- CLI: instance create <name> --minecraft <id|latest|latest-snapshot>,
  instance versions [--snapshots] [--old], instance java <instance>
  [--home|--auto] [--memory] [--args]; instance list shows the version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:04:34 +01:00
Ludovic Marie 9669600c38 Merge pull request #3323 from horizon-rust/feat/native-hooks-api-build
🤖 Agent: verify green; launcher-only (native_mods.rs, index native.rs). pr_ready green, launcher tests pass. Local parity of #3352's tree (which contains this PR): all affected harnesses pass except online_mode, which fails identically on main on this Mac (environment).
2026-10-28 21:58:51 +01:00
Ludovic Marie 528c322d16 Merge pull request #3321 from horizon-rust/feat/official-mods-turn-native
🤖 Agent: verify green. pr_ready green. Local parity (Mac mini, --changed origin/main): 36 of 37 pass. online_mode fails identically on main on this Mac (a stray GET / to its fake service, IndexError), so it is not this change. Registry test a_mod_may_turn_native_once and launcher test a_mod_that_turned_native_keeps_its_hmod_until_removed pass.
2026-10-28 21:58:46 +01:00
Ludovic MarieandClaude Opus 5.5 a9f8e64331 Native mods: horizon-mixins finds the launcher's cargo
`horizon-mixins apply` runs `cargo metadata`, but the launcher started it as a
plain command: on a computer with no Rust of its own every native build stopped
at E_METADATA. It now gets the launcher's toolchain like the build does.
Found by building zoom end to end with no system Rust, on macOS and Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:50:20 +01:00
Ludovic MarieandClaude Opus 5.5 01d0f45c2d Native mods: turn one off in Options > Mods
A native mod's row now toggles too. Off writes `mod.<id>:false`, so the
launcher's next build leaves the mod out, and `native_on` tells the mod to rest
until then; on again writes `true`. The WebAssembly row of the same id is no
longer listed: the native row stands for the mod. Asked by levzzz5154.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:46:13 +01:00
Ludovic MarieandClaude Opus 5.5 228af35f2a Launcher: Java runtimes for Minecraft: Java Edition instances
The first piece of Java instances: which Java a version needs and where
it comes from, as other launchers do it.

- java::Need reads a version JSON's javaVersion (major + Mojang
  component); versions from before that field ran Java 8 (jre-legacy).
- Runtimes are found without starting them, from each Java home's
  release file: Mojang's that Horizon downloaded (minecraft/runtime/ in
  the data folder, the one preparing uses) and installed ones (JAVA_HOME,
  the PATH, the usual installer folders).
- pick: an instance's own Java home, else the exact major (Mojang's
  first, never a newer one by itself), else None = download Mojang's.
- install downloads Mojang's runtime through mojang::java_runtime
  (sha1-checked, resumable), refusing on Linux without glibc.
- CLI: horizon-launcher java [list|install <major|component>].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:45:05 +01:00
Ludovic Marie abd1755f95 Merge pull request #2770 from socksprox/fix/2594-allay-travel-flying
🤖 Agent: read the diff at c216561da: `Allay.travel` -> `travelFlying(input, getSpeed())` via a new `travel_flying_speeds`, and `WaterAvoidingRandomFlyingGoal.getPosition` stroll targets. verify green, merges cleanly; the merger lands it once this head's parity run (queued) passes.
2026-10-28 21:42:59 +01:00
degrandoandClaude Opus 5.5 81f339814b Native mods build on Windows: MinGW-w64 with the toolchain, and a stage path horizon-mixins takes (#3377)
On Windows a native build stopped twice before compiling anything, then once while compiling:
- `windows-sys` 0.61 imports through raw-dylib, and on windows-gnu rustc makes those import
  libraries with dlltool, which runs the GNU assembler. rust-mingw's dlltool isn't on the build's
  PATH, and rust-mingw has no assembler, so even horizon-mixins didn't build.
- `canonicalize` gives a verbatim path there (`\\?\C:\...`), and horizon-mixins' output check stats
  `\\?\C:`, the volume itself: os error 1, E_IO.
- `ring`, `hidapi` and wasmtime compile C with `cc`, and rust-mingw's gcc only links.

The toolchain now brings MinGW-w64 on Windows (WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT
like rust-mingw, one zip pinned by SHA-256, its mingw64/ without the documentation) and puts its
bin/ on the build's PATH after Rust's. A toolchain installed before it counts as not installed, so
the next build installs it. The stage path loses the verbatim prefix of a drive path.

Measured on Windows 11 (Ryzen 7 9800X3D) with Veinminer 0.3.0 on Nightly 83f15fa: the launcher
installs the toolchain and builds the game in 8 min 3 s (6 min 57 s compiling), and the mod works
in game.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 01:34:22 +05:00
Ludovic Marie 0590a85d16 Merge pull request #3378 from horizon-rust/launcher-windows-tests
🤖 Agent: Windows launcher tests 219/219 pass with this (they failed on main: server delete Access denied, CRLF test); the remaining smoke failure is #3375's fix.
2026-10-28 21:32:38 +01:00
Ludovic Marie be6beb6ec7 Merge pull request #3375 from horizon-rust/ci-launcher-smoke-nightly
🤖 Agent: smoke test glob for the Nightly folder (#3342 broke every launcher build's smoke); CI green.
2026-10-28 21:30:32 +01:00
Ludovic MarieandClaude Opus 5.5 9cdfc5bd44 launcher: two Windows-only failures (server delete, a test's line ends)
- Deleting a stopped server always failed on Windows ("Access is denied"): the folder was
  moved while its lock file was open. The lock is released just before the move there; a
  supervisor starting in between holds it again and the move fails, so a live server stays.
- mojang_downloads_only_for_an_owner searched source text for "\n", which a Windows
  checkout writes as CRLF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 26af55000a launcher CI: the smoke test finds the installed Nightly
Since #3342 `install latest` installs the Nightly into versions/nightly-<date>-<commit>,
so the vX.Y.Z glob found nothing and every launcher build's smoke test failed (it also
blocks the next launcher release). Any version folder now, and a clear error when none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:18:49 +01:00
Ludovic Marie 3782473f52 Merge pull request #3358 from horizon-rust/nightly-approved-only
🤖 Agent: Nightly approval rule (Ludovic 10-28); tests.test_nightly + test_nightly_set 43 pass; CI green.
2026-10-28 21:15:30 +01:00
Ludovic Marie 9cbc96ee7b Merge pull request #3354 from horizon-rust/feat/launcher-owner-play
🤖 Agent: verified: pr_ready --quick green on the Mac mini (226 launcher tests), CI launcher/verify/parity-scope green; Play prepares then plays for an owner, download path and owner gate unchanged.
2026-10-28 21:12:53 +01:00
Ludovic Marie af0fef9196 Merge pull request #2754 from iLostName/server-list-f5-refresh
🤖 Agent: read the diff at 4c4fcedf4: F5 refreshes the server list (`JoinMultiplayerScreen.keyPressed` -> `refreshServerList`), only on the list screen. verify green, merges cleanly; the merger lands it once this head's parity run (queued) passes.
2026-10-28 21:09:32 +01:00
Ludovic MarieandClaude Opus 5.5 bbec03c14f Nightly: only pull requests the Team or Ludovic's agents approved at that head
The Nightly is the version every player installs: /nightly set and add refuse a pull
request without an approving review at its current head from a trusted reviewer or
ludovic111 (never its author). The scheduled build keeps the approved head when a newer
one waits for review, and drops pull requests nobody approved. Dry runs need none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:03:07 +01:00
Ludovic Marie 3c9bb38e88 Merge pull request #3345 from horizon-rust/feat/native-hook-points
🤖 Agent: verify green. pr_ready on this stack (with #3346): clippy, 1264 horizon-client tests, parity 76 affected all passing on the Mac mini. With no native mod every hook is an empty call. Proof: the four official native mods (horizon-rust/horizon-mod-*) apply with horizon-mixins and play on it, alone and together, in an offscreen game driven by horizon-ctl.
2026-10-28 20:54:06 +01:00
Ludovic MarieandClaude Opus 5.5 dc943f95e8 Launcher: Play downloads and prepares Minecraft for an owner
An owner with no Minecraft on the computer no longer needs to find
"Download and prepare": Play on a version not prepared yet prepares it
(from the player's install when there is one, else downloaded from
Mojang for the signed-in owner, Java included) and then starts the game,
as the official launcher does. Without an owner Play stays off.

With no install found, the Resources row offers the download alone and
the Minecraft folder row says none is needed. The CLI's not-prepared
error and the READMEs stop asking for a local install and Java.

The download path itself (download_for_owner, mojang.rs) is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:51:58 +01:00
Ludovic Marie 312218a47f Merge pull request #3087 from Domanffe/fix/crash-report-relative-paths
🤖 Agent: read the diff at da9950c75: crash reports strip the workspace prefix after turning `` into `/`, so Windows builds shorten paths too; other hosts unchanged. verify green. Queued for the merger.
2026-10-28 20:50:24 +01:00
Ludovic Marie 83b5c5ed53 Merge pull request #3075 from Domanffe/chore/hooks-find-python
🤖 Agent: read the diff at e8cb6ae83: the pre-commit and pre-push hooks run check_public_tree.py with the first of python3/python/py that really runs Python 3 (Git for Windows' Store stub exits non-zero), and fail loudly if none does. verify green. Queued for the merger.
2026-10-28 20:50:19 +01:00
Ludovic Marie 578cd262a5 Merge pull request #3342 from horizon-rust/feat/launcher-nightly-only
🤖 Agent: verify, launcher and parity-scope green at a1bdb0d22. pr_ready: launcher fmt/clippy clean, launcher tests 2 new (every launcher updates to the current Nightly; players of the newest release move to it), the only failure account::tests::the_whole_chain also fails on main on this Mac mini. CLI end to end in scratch homes: install latest / update installed nightly-<date>-<commit>, checksums signed for the nightly channel, worlds untouched. Heads-up posted in #horizon-team.
2026-10-28 20:46:38 +01:00
Ludovic MarieandClaude Opus 5.5 868256f1a8 The batch train builds Nightlies: no more vX.Y.Z game releases
Step 5 of Nightly only (Ludovic, 2026-10-28). release.yml keeps its triggers and its decision
(5 player-facing pull requests settled 5 minutes, or the oldest 6 hours, release-now,
skip-release, RELEASES_PAUSED, release-blocker, a commit where checks and parity-main passed),
counted since the commit the published Nightly was built on. When it decides, it starts
`nightly build` on that commit (`base`) instead of building vX.Y.Z:

- release_train: the last release of the game is the published `nightly`; one Nightly build at a
  time and at most 12 a day (nightly_set's cap), except for release-now and force (`urgent`).
- nightly-build.yml: an `urgent` input passes the cap; the notes say the launcher installs it.
- docs: release-flow (Game releases, Nightly only, Notes, Rolling back), versioning.

The last vX.Y.Z stays GitHub's latest for launchers and servers from before the switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:41:56 +01:00
Ludovic MarieandClaude Opus 5.5 1149c92cb8 Native mods: a settings page in Options > Mods
Native mods list themselves (native_mods) and give their settings page's
buttons (native_settings, native_settings_click); their keys show as Key
Binds rows and the player's choice is kept as the WebAssembly mods' keys
were (key.<id>.<name> in horizon-mods.txt). A native mod takes over the
WebAssembly mod of the same id, whose row says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:39:52 +01:00
Ludovic MarieandClaude Opus 5.5 360272fd79 Native mods: stable hook points (game/native_hooks.rs)
Empty, cfg-free and macro-free functions the game calls at fixed places
(frame, tick, HUD corners, keys, wheel, chat commands, zoom, minimap), for
horizon-mixins bundles to hook with HEAD, and helpers that read and draw
what Horizon's HUD extras do and keep a mod's settings in the WebAssembly
mod's own files. NATIVE_HOOKS_API = 1 names their version, for the
launcher to build mods made for it on any Horizon that has it. A test
keeps cfg and macros out. The frame and click counters now run without
WebAssembly mods, and the HUD extras always place their corners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:39:52 +01:00
Ludovic Marie 1d6d4cf0a0 Merge pull request #3335 from horizon-rust/t3/launcher-developer-options
🤖 Agent: launcher fmt/clippy/223 tests + pr_ready green; gate tests off/on; mojang_downloads_only_for_an_owner pins no download without an owner
2026-10-28 20:38:18 +01:00
Ludovic MarieandClaude Opus 5.5 a1bdb0d22d Launcher: the Nightly is the only game build, for everyone
Step 3 of Nightly only (Ludovic, 2026-10-28). Every launcher installs and updates only the
Nightly, with no setting:

- The update check reads the `nightly` pre-release from the release list it already fetches and
  offers it as the update, installed as `nightly-<date>-<commit>` after its checksums signed for
  the Nightly channel check. Nightlies rank after every release (`ReleaseOrder::Nightly`); a
  second build of the same day is an update too.
- A player on the newest release, on a Nightly or on nothing moves to the new Nightly at the next
  check; a release picked by hand and instances pinned to a version stay. Worlds live in the
  game's folder, so nothing touches them. Older Nightlies go once nothing plays them.
- The "Nightly builds" setting, its separate install path and its `nightly` key are gone; the old
  "Nightly" instance stays and keeps following the Nightly.
- Versions lists the current Nightly, the last week's and the releases from before; the update
  line names a Nightly by its title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:36:24 +01:00
Ludovic Marie 930252e2e4 Merge pull request #3326 from horizon-rust/ci/nightly-green-base
🤖 Agent: verify green; pr_ready on the Mac mini (399 Python tests incl. new nightly notes tests, launcher clippy/tests); notes checked on real data (56 PRs since Nightly 9b772b2). Step 1 of Nightly only: the Nightly still opt-in, now built on main's newest green commit and held by release-blocker.
2026-10-28 20:36:15 +01:00
Ludovic Marie 7dba3b4d73 Merge pull request #3331 from horizon-rust/feat/servers-follow-nightly
🤖 Agent: verify green; pr_ready on the Mac mini (horizon-server + horizon-release-sign 66 tests incl. new Nightly ordering and signature tests); install.sh installed the real current Nightly end to end in temp folders. Step 2 of Nightly only: servers follow the Nightly.
2026-10-28 20:34:26 +01:00
Ludovic Marie e0bf5f6599 Merge pull request #3329 from horizon-rust/fix/portal-rider-vehicle-timer
🤖 Agent: verify green on 74c2d13d77. New lan_e2e check portal_rider (--server-thread): fails before the review change (rider left after 75 ticks, its own 80-tick delay), passes after (5 ticks, arrives in the Nether on the same horse); portals check passes. Single player on the server thread: through in 0.33 s, still riding the same horse; in-frame unchanged. pr_ready all green (1272 horizon-client tests, clippy, 401 Python tests); parity --changed: all 41 affected harnesses pass.
2026-10-28 20:32:24 +01:00
Ludovic MarieandClaude Opus 5.5 c7504433c0 launcher: developer options section
A hidden Settings section, off by default; nothing changes for players.
Game downloads stay behind the existing owner check, which a test pins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:29:03 +01:00
Ludovic MarieandClaude Opus 5.5 41266ae70b Native mods: the Native mods instance gets the mods' settings from the default home
Mods installed in the launcher's default home build into the Native mods
instance, which starts empty: a player moving from the WebAssembly Zoom,
Minimap, FPS Counter or Keystrokes to its native version lost their
settings there. The build now copies each mod's storage folder and key
lines from the default home into the instance, once: nothing the instance
already has is overwritten.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:25:34 +01:00
Ludovic Marie a3acb5f0d2 Merge pull request #3082 from Domanffe/test/port-harness-symlink-skip
🤖 Agent: read the diff at f4d6f9c0e: test_port_harness skips the reports-link case where the host can't make symlinks. verify green. Queued for the merger.
2026-10-28 20:22:19 +01:00
Ludovic Marie 49b77529dd Merge pull request #2811 from ESHAYAT102/feat/server-delete-confirmation
🤖 Agent: read the diff at 84390f6dc: Delete server in its settings: refused unless the server is in the list, not installed as a service, and its supervisor lock (`.horizon/lock`) can be taken; the folder goes to the system trash, never `rm`. Unit tests cover a live lock, a non-server folder and a failing trash. verify and launcher green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:15 +01:00
Ludovic Marie dafa794cb3 Merge pull request #2762 from ESHAYAT102/fix/stopped-server-settings
🤖 Agent: read the diff at bcdbc252f: the sidebar's stopped-server entry now goes through `open_server`, which loads the server's settings instead of only setting `servers.open`. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:09 +01:00
Cosmic b7df7bd9e0 Merge pull request #3318 from degepie/fix/accept-26.3-republish-5
Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
2026-10-28 15:17:44 -04:00
Ludovic MarieandClaude Opus 5.5 40e2fe0608 Servers follow the Nightly: horizon-server update, install.sh and install.ps1
Step 2 of making the Nightly the only build players get (Ludovic, 2026-10-28). A server runs what
its players run:

- `horizon-server update` (and the supervisor's check, the panel's update) reads the `nightly`
  release, GitHub's latest while there is none, checks its SHA256SUMS signed for the Nightly
  channel and installs it as `nightly-<date>-<commit>`. Nightlies order after every release by
  date; another build of the same day is an update too (`update_available`).
- install.sh and install.ps1 install the current Nightly by default, under the same name.
- horizon-release-sign: `nightly_version` and `nightly_date`, shared with the launcher next.

Servers on an older horizon-server keep the last vX.Y.Z (GitHub's latest) until the release that
carries this one: it moves them over at their next update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:11:22 +01:00
Ludovic Marie a1e56a4d1b Merge pull request #3284 from horizon-rust/perf/sign-text-cache
🤖 Agent: verified. 3458 text signs in view, same scene/build profile, Mac mini: 124 → 222 fps (CPU 8.01 → 4.46 ms/frame), no-sign view unchanged (~1620 fps); look unchanged (pixel diff 6/268800, below run-to-run noise). New unit test checks the cached text against the uncached renderer across text/glow/state/font changes; entity batch equivalence test passes. Local: fmt, clippy -D warnings, horizon-client tests, Python tests and the 50 parity harnesses parity.py --changed picks all pass; CI verify green.
2026-10-28 20:03:48 +01:00
Ludovic Marie ab32a64d08 Merge pull request #2791 from levzzz5154/fix/spawn-egg-baby
🤖 Agent: read the diff at 70068076f: spawn-egg offspring rule split into `spawn_egg_is_for` / `spawn_egg_offspring` (`SpawnEggItem.spawnOffspringFromSpawnEgg`), behaviour unchanged, with a test. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:03:44 +01:00
Ludovic Marie f843f28a27 Merge pull request #2748 from levzzz5154/fix/turtle-walk-animation-rate
🤖 Agent: read the diff at 186198afe: test only: the turtle's limb speed follows its walk (`LivingEntity.updateWalkAnimation`, `TurtleMoveControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:01:08 +01:00
Ludovic Marie abcc2e7c80 Merge pull request #3238 from masa-masa-0314/fix/show-dialog-click
🤖 Agent: read the diff at 4d477067c ("Chat and book links: a show_dialog click event opens its dialog") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:01:04 +01:00
Ludovic Marie ac78481794 Merge pull request #3210 from PH4N7OMx/codex/windows-default-browser
🤖 Agent: read the diff at 998c94bee ("fix(windows): open URLs with the registered protocol handler") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:59 +01:00
Ludovic Marie 650601848f Merge pull request #3127 from kacpherek/fix/shulker-bullet-model
🤖 Agent: read the diff at 4a18a4bde ("Shulker bullets are drawn as the spinning bullet model, not a flat spr") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:55 +01:00
Ludovic Marie f9ea299952 Merge pull request #3124 from kacpherek/fix/mob-crowd-push
🤖 Agent: read the diff at f6b467eba ("Crowded mobs take cramming damage, as vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:50 +01:00
Ludovic MarieandClaude Opus 5.5 52dc27fa40 Nightly: build on main's newest green commit, hold for release-blocker, write player notes
Step 1 of making the Nightly the only build players get (Ludovic, 2026-10-28). Before every
launcher follows it, a Nightly gets a release's safety:

- `nightly.py pick` builds on main's newest commit where checks and parity-main passed
  (release_train.green_main_head), or the new `base` input, instead of main's tip; no green commit
  builds nothing.
- `publish` holds while a release-blocker is open (dry runs only warn, so a blocker's fix can
  still be tried with `/nightly dry-run`).
- The union/* fallback goes: with nothing picked a Nightly is main alone, so players never flip
  between a union and main from one build to the next.
- The notes list what reached main since the previous Nightly (New, Fixed, known issues,
  thanks), as a release's did, then the picked pull requests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 20:00:49 +01:00
Ludovic Marie b6eb028bb6 Merge pull request #3100 from Domanffe/perf/name-tags-in-range-first
🤖 Agent: read the diff at 5f8afb17f ("Name tags: copy a custom name only inside the tag's range") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:52 +01:00
Ludovic Marie 9dffa8a2a9 Merge pull request #3097 from Domanffe/perf/effect-panel-strings
🤖 Agent: read the diff at f7bb1ceb7 ("Effect panels: borrow the name and reuse the icon key") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:48 +01:00
Ludovic Marie cd89bc6dc1 Merge pull request #3095 from Domanffe/perf/server-row-sprite-path
🤖 Agent: read the diff at b9494af97 ("Server list: build each row's sprite key once") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:44 +01:00
Ludovic Marie 8a863ad378 Merge pull request #3092 from Domanffe/perf/creative-tab-by-reference
🤖 Agent: read the diff at d1ead6a5f ("Creative screen: draw the selected tab from a reference") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:40 +01:00
Ludovic Marie a27561da88 Merge pull request #3086 from Domanffe/perf/draw-slot-item-by-reference
🤖 Agent: read the diff at 3511d556e ("Inventory draw: a slot is drawn from a reference, not a copy") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:36 +01:00
Ludovic Marie ed7213df2e Merge pull request #3083 from Domanffe/perf/hud-paint-reuses-sprite-key
🤖 Agent: read the diff at 4bf633e8a ("HUD paint: build the sprite key in one reused buffer") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:50 +01:00
Ludovic Marie b1b27240d4 Merge pull request #3070 from iLostName/boat-view-clamp-every-turn
🤖 Agent: read the diff at a1b32304a ("boat: the view stops at 105 degrees at every turn, so it doesn't jitte") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:45 +01:00
Ludovic Marie 8a2a31e6a3 Merge pull request #3069 from iLostName/no-hitting-ridden-mob
🤖 Agent: read the diff at 7934a0b18 ("riding: the crosshair skips the mob being ridden, so it can't be hit") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:41 +01:00
Ludovic Marie 0264b39aa6 Merge pull request #3068 from iLostName/creeper-death-message
🤖 Agent: read the diff at 992c63eef ("death message: a creeper's blast names the creeper, not the last mob t") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:37 +01:00
Ludovic Marie 83935015cf Merge pull request #3067 from iLostName/pickup-fills-offhand
🤖 Agent: read the diff at a967ca434 ("item pickup: a stack in the off hand fills before the inventory, as ge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:33 +01:00
Ludovic MarieandClaude Opus 5.5 4e804cd813 Native mods on the hook points build on any Horizon that has them
A .hnmod may name the native hook points' version it uses ("hooks": 1 in
horizon-native.json) instead of pinning a commit. The launcher builds such
mods on the commit the other mods pin, else on the installed version's,
once that commit's NATIVE_HOOKS_API (read from GitHub) matches; else, with
no pin, on the commit they were tested on. Its own copy of each such mod
is re-pinned to the build's commit for horizon-mixins. So Horizon's
official mods build together, on the player's own game, with no
re-release for each Nightly. The mods feature is passed only when the
source still has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:46:58 +01:00
Ludovic MarieandClaude Opus 5.5 e2f3d1a85a Registry: a WebAssembly mod's entry may turn native once
An official mod moving to native hook points switches its registry entry
from `mod` to `native-mod`. Review now allows that one change (never
back): the entry's old `.hmod` versions stay published and are still
checked as `.hmod`s, while the index lists only its `.hnmod` versions.

The launcher's update to the native version keeps the player's `.hmod`:
the game runs it until a native build has the mod, then skips it.
Removing the mod removes both files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:15:38 +01:00
74c2d13d77 Merge origin/main into the portal rider travel branch
Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:02:22 +01:00
9ff0d98010 Let the vehicle take its riders through a portal at once
In vanilla 26.3 a passenger has no portal process of its own
(`Entity.canUsePortal(false)` is false for a passenger, so
`NetherPortalBlock.entityInside` skips it); the vehicle's goes through
on the next tick (`NetherPortalBlock.getPortalTransitionTime` is 0 for
anything that isn't a player) and `Entity.teleportCrossDimension`
carries the passengers. Here the rider's own timer decided, so a
survival player on a horse waited 80 ticks in the portal.

A server player who rides now starts no Nether or End portal process. A
horse, boat or minecart ridden only by server players uses the entity
portal path on a server, and once through, each rider's trip starts
with the vehicle (the trip's `vehicle`). Out of the End every rider must
have seen the credits (`Entity.canTeleport`). The level the vehicle
waits in stays open until it has moved. Elsewhere (an in-frame LAN
host) a ridden vehicle stays rather than leave its rider behind.

lan_e2e gets a `portal_rider` check (--server-thread).

Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:54:57 +01:00
42be3018dd Merge origin/main into the portal rider travel branch
main now sets a 300-tick portal cooldown with `set_portal_cooldown(id)`
(#2352), so the vehicle's arrival uses it; the world spawn trip
(#2355) gets the new `source` and `vehicle` fields; the parked level
keeps both the public mob list and the leash knot ids (#2123).

Co-Authored-By: levzzz5154 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:39:01 +01:00
degepieandClaude Opus 5.5 3c98062662 Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
Mojang republished the 26.3 version JSON again. The live copy (5df1795e)
points at asset index 34 917ff6d7; neither was in research-lock.json, so
`horizon-prepare --prepare` from a fresh official-launcher install stops
with "is not a 26.3 version file Horizon knows", and the sounds step warns
about an unknown asset index. Jars, libraries and javaVersion are
unchanged; the index differs from 0c8f43b2 only in 142 minecraft/lang/*
objects and pack.mcmeta (uz_uz moved in the language list).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-28 21:51:58 +05:00
Ludovic MarieandClaude Opus 5.5 0281365907 Signs: keep laid-out sign text between frames, batch entity quads in runs
A wall of 3458 text signs ran at 128 fps against 1642 without them: each
frame laid every sign's text out again glyph by glyph (Font::glyph_of was a
quarter of the main thread), then regrouped every entity quad by material
through a per-quad index list.

- block_entity_render: a sign's text vertices are kept relative to its
  block and moved to the camera each frame. They are laid out again when
  the sign data, block state, light, glowing outline distance or font
  (Font::epoch: a new font, a glyph cache reset, the Unicode and Japanese
  options) changes; signs out of view for 100 ticks are dropped.
- gfx/entity_batch: quads are grouped in runs of one material and whole
  runs copied (2-4x faster on model-like input, the same when every quad
  changes material).

Fixes #1461 (the cost per sign; the vanilla text distance of 64 blocks
and the look are unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:08 +01:00
masa-masa-0314andZCode 4d477067c3 Chat and books: open a show_dialog click event's dialog
A server may put a show_dialog click event on a chat line or a book
page; ClickEvent.Action.SHOW_DIALOG allows it from a server, and vanilla
opens the dialog through Screen.defaultHandleGameClickEvent. Horizon
dropped the click event while parsing the component (no arm for it in
component.rs, no ClickEvent variant to carry it), so clicking the text
did nothing.

The dialog now survives as ClickEvent::ShowDialog (a registry id or the
inline dialog), parses from click_event, round-trips back out, and both
click handlers (chat and BookViewScreen) open it with show_dialog_ref,
the same path a server's show_dialog packet takes.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 22:52:46 +08:00
PH4N7OMxandOpenAI Codex 998c94bee3 fix(windows): open URLs with the registered protocol handler
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 14:46:01 +01:00
Kacpherek 4a18a4bde4 Draw the shulker bullet with its model instead of a sprite
The bullet fell through to the item billboard and showed the whole spark texture. Vanilla ShulkerBulletRenderer draws the tumbling bullet model plus a 1.5x translucent copy at full block light.
2026-10-28 10:47:31 +01:00
Kacpherek f6b467eba9 Hurt crammed mobs by 6 at the max entity cramming limit
Mobs only drew the cramming random and never took damage, so a crowd was never thinned. Vanilla's LivingEntity.pushEntities hurts a mob by 6 on a quarter of the ticks while max_entity_cramming or more non-riding entities are pushable around it.
2026-10-28 10:47:13 +01:00
AlexanderandHermes Agent 5f8afb17fc Name tags: copy a custom name only inside the tag's range
name_tags cloned every named mob's custom name each frame and dropped the tag
afterwards when the mob was beyond 64 blocks. Test the distance first, so the
copy happens only for a mob the frame can show.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:26:00 +03:00
AlexanderandHermes Agent f7bb1ceb79 Effect panels: borrow the name and reuse the icon key
The panel list is painted every frame the inventory is open. An unclipped
name was cloned per panel and the icon key was a fresh format! per panel; the
name is borrowed and the key is built in one buffer now.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:16:09 +03:00
AlexanderandHermes Agent b9494af976 Server list: build each row's sprite key once
The row made the short sprite name (format! or to_owned) and then wrapped it
in format!("gui/sprites/{sprite}") for the atlas. Build the full key in the
match arm and hand it straight to sprite_scaled.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:14:14 +03:00
AlexanderandHermes Agent d1ead6a5fa Clippy: tab is already a reference
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:09:17 +03:00
AlexanderandHermes Agent 3511d556ef rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:58:20 +03:00
AlexanderandHermes Agent 4ff7118db7 Creative screen: draw the selected tab from a reference
tab_headers() builds a header per tab without the item lists, and the caller
then cloned the selected one again: three Strings copied every frame the
screen is open. Borrow it instead.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:55:47 +03:00
AlexanderandHermes Agent da9950c75b Crash report: strip the workspace prefix on Windows too
The root came from CARGO_MANIFEST_DIR.strip_suffix("crates/horizon-client"),
which never matches a Windows manifest (backslashes), so every workspace frame
was printed as /src/... instead of crates/horizon-client/src/... Normalise the
separators of the manifest and of each frame before shortening.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:45:04 +03:00
AlexanderandHermes Agent 626068da3a Inventory draw: a slot is drawn from a reference, not a copy
draw_slot_item needs neither ownership nor &mut self: it reads the carried
stack only for a drag. Taking `&Option<ItemStack>` and &self drops the copy
every caller made, including the two the creative hotbar made per slot each
frame (and one clone per hovered slot becomes a clone only when hovered).

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:40:11 +03:00
AlexanderandHermes Agent 4bf633e8a9 HUD paint: build the sprite key in one reused buffer
paint() runs for every drawn HUD call each frame and made a fresh String per
sprite to look it up in the atlas: sprite.to_string() for a mob_effect and
format!("gui/sprites/{sprite}") otherwise. One buffer outside the loop keeps
the same keys without the allocation.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:33:46 +03:00
AlexanderandHermes Agent f4d6f9c0ed Tests: skip the reports-link case when the host cannot link
os.symlink needs Developer Mode or an elevated token on Windows, so the test
errored with WinError 1314 instead of exercising own_reports. Skip it there
and keep it on hosts that can link.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:30:03 +03:00
AlexanderandHermes Agent e8cb6ae83b Hooks: run the public tree check through a Python that works
Git for Windows puts a Microsoft Store stub on PATH as python3: it prints
"Python" and exits 49, so both hooks failed before running the check. The
hooks now try python3, python and py and use the first that is Python 3.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:14:16 +03:00
Kacpherek 99008baad9 Hold items at the middle of a slim arm
A player with a slim skin held items at the wide arm's hand, half a pixel off the 3-pixel arm. PlayerModel.translateToHand shifts the arm by 0.5 toward the hand before the item's offset, as SkeletonModel does by 1.
2026-10-28 07:24:11 +01:00
Kacpherek e1bc2cbea3 Show banner patterns on shields sent by a server
The banner_patterns and base_color components were read past on the wire, so a stack from a server never carried them and its shield drew plain. Decode both (reference patterns; direct ones stay raw) and write them back, as vanilla's BannerPatternLayers and DyeColor stream codecs do.
2026-10-28 07:24:11 +01:00
iLostName 7b93b310a0 death: the player hears their own death sound on a server and in single player
LivingEntity.handleEntityEvent 3 plays it for the dying player; the server's goes to the others. Fixes #3021.
2026-10-28 06:52:29 +01:00
iLostName a1b32304a3 boat: the view stops at 105 degrees at every turn, so it doesn't jitter
Fixes #2983.
2026-10-28 06:52:29 +01:00
iLostName 7934a0b180 riding: the crosshair skips the mob being ridden, so it can't be hit
Fixes #2870.
2026-10-28 06:52:28 +01:00
iLostName 992c63eefb death message: a creeper's blast names the creeper, not the last mob that hit
The creeper is out of the mob list when its blast lands, so its name now comes with the explosion event. Fixes #2978.
2026-10-28 06:52:28 +01:00
iLostName a967ca4340 item pickup: a stack in the off hand fills before the inventory, as getSlotWithRemainingSpace does
Fixes #2865.
2026-10-28 06:52:28 +01:00
iLostName 0112a6feac item pickup: one sound per item, played by the client at the item
The server sent its own pickup sound beside TakeItemEntity, so single player heard two. Fixes #3029, fixes #2861.
2026-10-28 06:52:27 +01:00
Kacpherek 9d32bebad3 Show the happy particle when feeding a baby horse on a server
AbstractHorse.handleEating draws it on the client's own copy of the mob; Horizon drew it only on the server's level, which a server's client never sees. Same for llamas and camels.
2026-10-28 06:26:23 +01:00
Kacpherek e24394ca3a Drop the hand after placing or using an item on a server
The server-client use path never called LocalPlayer.itemUsed, so the held item stayed up after a placement. Vanilla drops it after a swinging useItemOn that spent the item (or in creative) and after every successful useItem.
2026-10-28 06:09:40 +01:00
Kacpherek a5ee1e588c Leave a server at once instead of waiting for the reader thread
On Windows a socket shutdown doesn't wake a read already waiting, so Disconnect froze the window until the server closed the connection. Vanilla closes the connection and moves on.
2026-10-28 05:59:37 +01:00
feeldev 752fc2170a fix(client): ConnectScreen as 26.3 draws it, with Cancel
Joining a server showed a flat dark fill with the status and the address.
ConnectScreen draws the menu background over the blurred panorama, the
status centred at height / 2 - 50, and a 200-wide Cancel at height / 4 +
132 that aborts the connection and goes back; Escape does nothing there
(shouldCloseOnEsc is false). Once logged in, the LevelLoadingScreen's
Loading terrain takes over until the player is placed.
2026-10-27 18:05:11 -05:00
feeldev 356b69aea7 fix(client): the window keeps drawing while single player's server starts
Minecraft.doWorldLoad shows the LevelLoadingScreen and keeps running the
client while its server prepares the world. Horizon's window waited on the
server thread's ready message instead, frozen (not responding on Windows)
for the whole load. The server's answer is now polled each frame from
resume_world_entry, the LevelLoadingScreen's title is drawn meanwhile,
and the window joins once the server is ready. Runs without a window and
the synchronous harness and control runs still wait for it, as before.
2026-10-27 18:02:04 -05:00
feeldev fdefcfeac0 perf(client): read the loot tables while joining a server
On a server's world the loot tables were first read on the game thread by
the first block placed (StepContext's loot), a ~0.6 s freeze on a phone.
They are now read on a helper thread as the connection starts, like the
world screens' preload does for local worlds, and kept from then on.
2026-10-27 16:54:03 -05:00
Ludovic MarieandClaude Opus 5.5 9fcfdaa1ea Portal arrivals: rustfmt, and name vanilla's portal ticket
resolve_arrivals keeps its own doc comment; the area an arrival keeps is
vanilla's TicketType.PORTAL, which has no ENTITY type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:47 +01:00
Ludovic MarieandClaude Opus 5.5 3be828d012 Merge main into #2813: reach first, then spawn protection, then the border
handleBlockBreakAction refuses a dig out of reach before it looks at spawn
protection; ServerLevel.mayInteract (spawn protection, then the world
border) comes after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 70068076f2 Keep set_baby's doc comment on set_baby
The two new functions went in between it and its comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:39 +01:00
Kacpherek 1deb3bd36c Send decorated pot sherds to LAN guests
Decorated pots showed no sherds for LAN guests. A pot is not a block entity here, so the hub and the chunk encoder read the saved tag for it and send the sherds, loot table and item (DecoratedPotBlockEntity.getUpdateTag, saveCustomOnly). Inserting into a pot marks it for update, and a client stores a pot update with the chunk.
2026-10-27 21:59:46 +01:00
Kacpherek 2ad7b68710 Send the trial spawner mob to LAN guests
Trial spawners never showed their mob for LAN guests. The update tag now carries the spawn data the mob is drawn from, as TrialSpawnerStateData does. next_mob_spawns_at is left out: the client never reads it and it depends on the block state.
2026-10-27 21:59:46 +01:00
Kacpherek 203fb2db89 Send the vault display item to LAN guests
Vaults never showed their display item for LAN guests. The update tag now carries the shared data that holds it (VaultBlockEntity.getUpdateTag). A guest's update keeps the turn the client has built up, since the tag does not carry it.
2026-10-27 21:59:46 +01:00
Kacpherek 2f7edd1524 Send banner patterns to LAN guests
Banners showed no pattern layers for LAN guests and the single-player window. The update tag now carries the patterns, the custom name and the components, as BannerBlockEntity.getUpdateTag (saveWithoutMetadata) does.
2026-10-27 21:59:46 +01:00
RVZO6andClaude Opus 5.5 32a0a11fed Keep a move behind a dig and its arm swing for the next tick
The hub hands a dig (handlePlayerAction) to the game after its pass, and
a move queued right behind it waited for the next tick so it was not
checked against the block the player had just broken. A creative break
sends the arm swing (Punch) between the dig and the move, so the move
was handled in the same pass: the block was still there, the move was
refused and the player sent back. Digging straight down, the player
sank into the block, was put back on top of it a tick later, then fell.

Any move left in the queue now waits, with what is queued before it,
as vanilla handles the packets in order. Traced with horizon-ctl in a
creative world: the player rose back after 6 to 7 of 8 breaks before,
after none now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:47:14 -04:00
sirrayiandDevin 0580626784 Server: refuse digging and using blocks outside the world border
ServerLevel.mayInteract also requires WorldBorder.isWithinBounds(pos): START_DESTROY_BLOCK outside the border starts no dig, and UseItemOn uses nothing there (the block updates are still sent back). Part of #2743.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:58:38 +00:00
levzzzandminimax-m3.1-flash 65bca4ce98 net: skip a compressed frame that carries no packet
A server may send a compressed frame whose uncompressed length is 0 and
which holds no packet: `ConnReader::recv` read the packet id from the
empty body and failed with "needed 1 bytes, 0 left", which ended the
connection. Vanilla skips such a frame: its PacketDecoder only decodes a
packet from a readable buffer, so an empty frame produces no packet and
the connection stays up.

A frame with no bytes at all is a corrupted frame in vanilla ("Frame
length cannot be zero"), so the frame splitter now says that instead of
failing later with a missing byte.

Fixes #2739

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:08:49 +05:00
sirrayiandDevin 2812cf201d Protocol: don't panic on malformed chunk sections, compressed frames or legacy pings
A server sending a palette wider than 64 bits divided by zero in storage_len (and 33-64 bits overflowed shifts); SimpleBitStorage allows at most 32. A non-canonical data-length VarInt in a compressed frame was misread. An empty first read in the legacy ping handler indexed past the end.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin 9bbc54bd4c Server: refuse block actions out of a player's reach
Breaking and using blocks (START/STOP_DESTROY_BLOCK, UseItemOn) were carried out for any position a client sent, so a modified client could dig or place anywhere in loaded chunks. Check isWithinBlockInteractionRange(pos, 1.0) and the hit point as vanilla's handleBlockBreakAction and handleUseItemOn do.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:50 +00:00
socksproxandDevin (SWE-2 High) 4fdc93257f Let portal arrivals tick in their destination without a player
An item or mob sent through a portal into a playerless dimension waited
forever: resolve_arrivals needed lit chunks, the level never streamed any,
and levels_to_tick skipped it entirely, so its Age stayed frozen until
someone followed and it was picked up intact (#1528). Vanilla's portal
move lands the entity under an ENTITY ticket, which keeps the chunk
entity-ticking while it ages and despawns on time.

Pending arrivals now add their landing chunk to the level's streaming
extra areas and keep their destination level in levels_to_tick, so it
lights, resolves, and ticks them just as if a player stood there.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:21:24 +01:00
Eshayat Al-WasiuandCodex 84390f6dc1 Match server deletion input to button row width
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 9d191c28f2 Narrow server deletion confirmation input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5d42108a98 Place deletion confirmation help below input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 1972ef3bef Use shared settings row styling for server deletion
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex e8145b07fa Remove redundant server deletion confirmation hint
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5e7295a631 Align server deletion controls with the settings rows
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 761a770174 Emphasize the server name in the delete confirmation
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 7f0a61954b Add a confirmed server delete action to launcher settings
Move stopped servers to the system trash after name confirmation. Refuse active supervisor locks and background services, and refresh the sidebar after removal.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
asownerytandClaude Opus 5.5 b38fe43b09 Jukebox: keep the disc on the server thread; the off hand never ejects it
JukeboxBlock.useItemOn returns TRY_WITH_EMPTY_HAND when a disc is in, and
only the main hand goes on to useWithoutItem (popOutTheItem). The client now
predicts that the jukebox takes the click, so no off-hand UseItemOn follows
the insert, and the server ignores an off-hand turn on a full jukebox.

Fixes #2775

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 01:30:20 +13:00
levzzz 91de744250 Merge remote-tracking branch 'upstream/main' into fix/spawn-egg-baby 2026-10-27 17:24:11 +05:00
levzzzandminimax-m3.1-flash 1654cb51db The spawn egg's offspring is a named, tested rule
The behaviour is in main (#2275): `Mob.checkAndHandleImportantInteractions`
uses a spawn egg of the mob's own kind on a mob, and
`SpawnEggItem.spawnOffspringFromSpawnEgg` spawns the offspring as a baby
of that kind at the parent's position, and one egg less in hand.

The rule now lives in the library, where it is named and tested:
`spawn_egg_is_for` (the egg is for the mob's own kind) and
`spawn_egg_offspring` (the baby, with the parent's variant and colour).

Fixes #2269

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:24:09 +05:00
levzzzandminimax-m3.1-flash 35581f52e2 Keep the clippy rules happy in the difficulty test
The level is built with the struct update syntax instead of a field
assignment after Default::default(), which clippy's
field_reassign_with_default rejects.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:46:15 +05:00
socksproxandDevin (SWE-2 High) c216561daa Allays fly: their travel is travelFlying and their stroll hovers
An allay stood on the ground and fell when the block under it broke:
Mob.travel sent it down the ordinary air travel, which applies the
0.08 gravity attribute whenever noGravity is off. Vanilla's Allay.travel
is travelFlying(input, getSpeed()) — a travel with no gravity term —
so an allay hangs mid-air even before a goal moves it.

- Family::Allay routes to allay::travel, which calls travelFlying with
  the move control's current speed (LivingEntity.speed), like
  Allay.travel(input, getSpeed()); travel_flying is split so the three
  speeds can differ per mob.
- Stroll::flying picked a land position, so the stroll never produced
  a MoveTo mid-air. WaterAvoidingRandomFlyingGoal.getPosition tries
  HoverRandomPos (8, 7, pi/2, 3, 1) on the view direction, then
  AirAndWaterRandomPos (8, 4, -2); Stroll::flying now does the same.
  The wither registers the same vanilla goal and benefits equally.

Verified against the mob_ai probe: an allay spawned at y=-55 stays at
-55, then RandomStrollGoal fires and it drifts to air targets, never
falling — where it used to drop to the ground.

Fixes #2594.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 12:40:16 +01:00
levzzzandminimax-m3.1-flash 618d20bd08 The world's difficulty follows the level data
The difficulty is a property of the world: vanilla keeps it in the level
data (`PrimaryLevelData.getDifficulty`/`setDifficulty` over
`Data.difficulty_settings`), so it survives leaving and rejoining the
world and a downloaded world keeps the difficulty it was made with.

Horizon read the difficulty from the level data when the world opened,
but `/difficulty` only changed the game's own copy, so the level data
kept the old value and wrote it back: the world came back on normal.
The level data now follows the game, both when the command changes the
difficulty and when a LAN guest asks the host to change it.

Fixes #2571

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:22:50 +05:00
asownerytandClaude Opus 5.5 985ece811b Stop the menu music when a world loads
Minecraft.setLevel -> updateLevelInEngines stops every sound
(SoundManager.stop), so the title-screen track ends on joining and the
music manager then waits the level's music delay. Horizon only stopped
sounds when leaving a world, so music.menu kept playing in it.

- A server's client (single player's server thread, LAN, multiplayer)
  stops every sound when it makes a new level: on Login and on a
  Respawn into another level. A dimension-changing Respawn also calls
  MusicManager.stopPlaying, as handleRespawn does.
- The in-frame path does the same when the opened world's first player
  is placed.
- Vanilla's Minecraft.player exists from handleLogin on; here it is made
  at the first teleport, so getSituationalMusic counts a logged-in client
  as in the world. Otherwise the menu's 0..10-tick replace delay would
  start the first game track almost at once.
- test_music_parity.py scripts the join: a menu-like track, stopall,
  game music, then a dimension change's stop.

Fixes #2746

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:18:31 +13:00
levzzzandminimax-m3.1-flash c734c72fc9 The world's difficulty survives leaving and rejoining it
Vanilla keeps the difficulty in the level data: `Data.difficulty_settings`
(`LevelSettings.DifficultySettings`) holds the name of the difficulty, the
hardcore flag and the lock. Horizon read and wrote `Data.Difficulty`, a
byte that 26.3 no longer has, so the world always opened on normal and
`/difficulty` never reached the file: the level data writes the
difficulty settings, and the game patched a key that was not there.

The difficulty now comes from and goes to `difficulty_settings`, and the
level data follows the game, so a world set to hard is hard when it is
opened again and a downloaded world keeps the difficulty it was made
with.

Fixes #2571

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:16:40 +05:00
Eshayat Al-WasiuandCodex bcdbc252fe Load server details when opening from the launcher sidebar
Use the shared open_server path so stopped servers load settings immediately and switching servers clears stale detail state.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 17:07:42 +06:00
levzzzandminimax-m3.1-flash 186198afe6 Pin the turtle's walk animation to vanilla's rate
The turtle's flailing feet (#1776) were the walk animation running at its
maximum: the generic MoveControl walked the turtle at its full movement
speed (0.25), so LivingEntity.updateWalkAnimation's min(distance * 4, 1)
saturated at 1.0 and TurtleModel swung the legs at the maximum rate and
amplitude. PR #2565 gave the turtle its own pace, but its tests only pin
the forward input the control sets, not the rate of the animation the
reporter saw. This test pins the symptom: the turtle covers 0.18447
blocks a tick and its walk animation runs at 0.73789, vanilla's
min(distance * 4, 1) for that pace.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:44:33 +05:00
iLostName 4c4fcedf40 server list: F5 refreshes it, as in vanilla 2026-10-27 11:30:44 +01:00
iLostName de8dd66ac2 connection: try a server's addresses side by side, 250 ms apart 2026-10-27 11:24:59 +01:00
iLostName 6fc2006447 dns: leave out scoped IPv6 name servers on Windows, as JNDI does 2026-10-27 11:24:59 +01:00
levzzzandminimax-m3.1-flash 7e68f1ed32 Remove the duplicate rabbit arm in entity_nbt
The merge of #2585 left two "minecraft:rabbit" arms in the same match, so
the second one is unreachable and clippy's -D warnings rejects the build
(upstream's own checks job is red on it since 0e793be2a). The first arm
already writes RabbitType and MoreCarrotTicks, so the second one is dead
code; its note about the legacy id moves to the arm that runs.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:14:59 +05:00
levzzzandminimax-m3.1-flash 44850bdd55 Merge upstream/main into the portal rider travel branch
The level that is not in place needs both the lookup by dimension (this
branch) and the list of the parked levels (upstream).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 14:58:02 +05:00
levzzzandminimax-m3.1-flash 33e81f464e Keep a rider with the vehicle it rides through a portal
A server player who rode a horse, a boat or a minecart into a Nether
portal arrived in the other dimension on foot: the vehicle went through
the portal on its own (as an entity, with a new entity id) while the
player travelled alone, and the ride was broken. Vanilla carries the
passengers with the vehicle (`Entity.teleportCrossDimension` recreates
the vehicle in the other dimension and the passengers ride it again),
and a passenger does not use a portal on its own (`Entity.canUsePortal`,
`NetherPortalBlock.entityInside`). The vehicle a server player rides
now travels with it: it is left out of the entity portal path, and it
moves to the destination level with the player when the trip arrives,
keeping its entity id (and its UUID and the compound it was loaded
with), so the ride is never broken. It stands where its rider arrives
and does not go back through the exit portal for a while
(`Entity.setPortalCooldown`).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:30:03 +05:00
139 changed files with 6582 additions and 1095 deletions

No files matched your search

+10 -1
View File
@@ -1,2 +1,11 @@
#!/bin/sh
exec python3 tools/check_public_tree.py
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
+10 -1
View File
@@ -1,2 +1,11 @@
#!/bin/sh
exec python3 tools/check_public_tree.py --pre-push
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py --pre-push
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
+3 -1
View File
@@ -192,9 +192,11 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
+34 -12
View File
@@ -1,11 +1,12 @@
name: nightly build
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
# on. The previous nightly stays a week as nightly-<date>.
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
@@ -13,11 +14,11 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
@@ -56,6 +57,14 @@ on:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
base:
description: The commit of main to build on (default main's newest with green checks and parity-main)
type: string
default: ''
urgent:
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
type: boolean
default: false
permissions:
contents: read
concurrency:
@@ -71,6 +80,7 @@ jobs:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
actions: read # main's newest green checks and parity-main runs (the base)
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
@@ -96,6 +106,8 @@ jobs:
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
NIGHTLY_BASE: ${{ inputs.base }}
NIGHTLY_URGENT: ${{ inputs.urgent }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
@@ -180,6 +192,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
with:
@@ -194,9 +207,17 @@ jobs:
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: The smoke test drew a world
# No release-blocker check: a nightly is how blockers get tested.
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.pick.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 as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
@@ -208,7 +229,8 @@ jobs:
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
@@ -249,7 +271,7 @@ jobs:
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
else
text="the build failed, nothing was published: $RUN"
fi
+27 -139
View File
@@ -1,22 +1,15 @@
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,
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
# 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 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
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
on:
workflow_run:
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
@@ -28,17 +21,19 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
description: Decide only; start no Nightly build
type: boolean
default: true
force:
description: Release even without player-facing changes, before merges settle or while paused
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
type: boolean
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
# The checks, parity-main and nightly build runs (decide).
actions: read
concurrency:
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
@@ -54,9 +49,8 @@ jobs:
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -76,127 +70,21 @@ jobs:
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
build:
nightly:
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
needs: decide
if: needs.decide.outputs.release == 'true'
# Every platform's archive, and macOS's smoke test (game-build.yml).
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
trusted: true # main: the release runner, the release cache, no shared sccache
sign:
needs: [decide, build]
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" 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
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 5
permissions:
contents: write
issues: read
actions: write # starts `nightly build`
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
- env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ needs.decide.outputs.head }}
URGENT: ${{ needs.decide.outputs.urgent }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
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:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
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 }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
Generated
+37
View File
@@ -876,6 +876,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
@@ -1398,6 +1404,7 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1877,6 +1884,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2691,6 +2717,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
+3 -3
View File
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -93,7 +93,7 @@ Commands block until the action is done and reply with what happened; errors are
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
+8
View File
@@ -50,6 +50,9 @@ pub enum ClickEvent {
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
ClickEvent::ShowDialog(dialog) => json!({
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+2
View File
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
@@ -1121,6 +1121,7 @@ pub fn main() -> horizon_client::Result<()> {
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
@@ -597,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
line.to_owned()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
/// the sign's centre.
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(camera)
< 256.0
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
fn sign_text_calls(
@@ -757,6 +769,9 @@ pub struct Scene<'a> {
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
}
pub struct BlockEntityRenderer {
@@ -768,7 +783,30 @@ pub struct BlockEntityRenderer {
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
#[derive(Default)]
struct SignTexts {
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
/// Game time of the last sweep of signs out of view.
swept: i64,
}
struct SignTextEntry {
state: u32,
data: crate::signs::SignData,
packed: u8,
/// Within the glowing text outline's distance (only kept for glowing text).
near: bool,
glyphs: u64,
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
}
/// Game ticks a sign's text is kept after it was last drawn.
const SIGN_TEXT_KEEP: i64 = 100;
const MODELS: [(&str, &str); 27] = [
("chest", "minecraft:chest#main"),
("double_chest_left", "minecraft:double_chest_left#main"),
@@ -898,6 +936,7 @@ impl BlockEntityRenderer {
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
fn kind(&self, state: u32) -> &Kind {
@@ -932,11 +971,81 @@ impl BlockEntityRenderer {
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
self.sign_text(scene, *pos, *state, origin, packed, out);
continue;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
}
}
/// A sign's text from the cache, laid out again when its data, light,
/// state, outline or font changed.
fn sign_text(
&self,
scene: &Scene,
pos: [i32; 3],
state: u32,
origin: Vec3,
packed: u8,
out: &mut Vec<Vertex>,
) {
let Some(data) = (scene.sign)(pos) else {
return;
};
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
let Ok(mut cache) = self.sign_texts.lock() else {
return;
};
let now = scene.game_time;
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
cache.swept = now;
cache
.signs
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
}
let glyphs = scene.glyphs;
let signs = &mut cache.signs;
if !signs.get(&pos).is_some_and(|e| {
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
&& e.data == *data
}) {
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
vertices.clear();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::IDENTITY,
packed,
diffuse_lights(scene.nether),
false,
true,
&mut vertices,
);
signs.insert(
pos,
SignTextEntry {
state,
data: data.clone(),
packed,
near,
glyphs,
used: now,
vertices,
},
);
}
let Some(entry) = signs.get_mut(&pos) else {
return;
};
entry.used = now;
out.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
}
/// One block entity at `pos` drawn under `base` with the given packed light
/// (the parity fixture's entry point).
pub fn render_block(
@@ -1168,13 +1277,7 @@ impl BlockEntityRenderer {
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
* Mat4::from_scale(s * 0.010_416_667);
}
let near = DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(scene.camera)
< 256.0;
let near = sign_text_near(pos, scene.camera);
let calls = sign_text_calls(scene, data, text, m, packed, near);
match scene.texts {
Some(sink) => sink.borrow_mut().extend(calls),
@@ -1954,6 +2057,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -2154,6 +2258,128 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
kinds: vec![
Kind::Sign {
hanging: false,
wall: false,
angle: 180.0,
},
Kind::Sign {
hanging: true,
wall: true,
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let text = |line: &str, glowing| crate::signs::SignText {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
};
let first_text = crate::signs::SignData {
front: text("one", false),
..Default::default()
};
let changed = crate::signs::SignData {
front: text("changed", false),
..Default::default()
};
let glowing = crate::signs::SignData {
back: text("glow", true),
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph = |c: char| {
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
v0: 0.0,
u1: u + 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
|_| Some(&changed),
|_| Some(&glowing),
);
let mut scene = Scene {
camera: DVec3::new(3.25, 70.5, -4.75),
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
renderer.render(scene, [(pos, state)].iter(), &mut cached);
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let mut original = Vec::new();
renderer.render_one(
scene,
pos,
renderer.kind(state),
Mat4::from_translation((corner - scene.camera).as_vec3()),
world.light(pos[0], pos[1], pos[2]),
diffuse_lights(false),
false,
true,
&mut original,
);
assert!(!cached.is_empty());
assert_eq!(cached.len(), original.len());
for (a, b) in cached.iter().zip(&original) {
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
}
cached.len()
};
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
let first = check(&scene, pos, 0);
// The camera moves: the same text, moved.
scene.camera.x += 0.5;
check(&scene, pos, 0);
// New text, glowing text near and far, another state, other glyphs.
scene.sign = &changed_sign;
assert_ne!(check(&scene, pos, 0), first);
scene.sign = &glowing_sign;
check(&scene, pos, 0);
scene.camera.z -= 20.0;
check(&scene, pos, 0);
check(&scene, pos, 1);
check(&scene, other, 1);
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
@@ -2214,6 +2440,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
+5 -3
View File
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
/// frames go. Paths inside the workspace lose the build machine's prefix.
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
// The workspace as it was on the machine that built this binary (it need not exist here).
let manifest = env!("CARGO_MANIFEST_DIR");
// A report names workspace files the same way on every host, whatever
// separator the build machine used.
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
let root = manifest
.strip_suffix("crates/horizon-client")
.unwrap_or(manifest);
.unwrap_or(&manifest);
let mut frames: Vec<(String, Option<String>)> = Vec::new();
for line in backtrace.lines() {
let line = line.trim();
if let Some(at) = line.strip_prefix("at ") {
if let Some(frame) = frames.last_mut() {
frame.1 = Some(short_path(at, root).to_owned());
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
}
} else if let Some((n, name)) = line.split_once(": ")
&& n.chars().all(|c| c.is_ascii_digit())
+1
View File
@@ -300,6 +300,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
+15
View File
@@ -130,6 +130,8 @@ pub struct Font {
atlas: (u32, u32),
region: (u32, u32),
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
/// "Force Unicode Font": the `uniform` filter option.
pub uniform: bool,
/// "Japanese Glyph Variants": the `jp` filter option.
@@ -559,6 +561,11 @@ impl Region {
Some((ax, ay))
}
}
/// A value no font has had yet (`Font::epoch`).
fn next_generation() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl Font {
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
/// `atlas` size) with `region` the reserved rows.
@@ -603,6 +610,7 @@ impl Font {
.map_or(0, |d| d.as_nanos() as i64),
),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
japanese: false,
};
@@ -661,6 +669,13 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
self.generation.set(next_generation());
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
pub fn epoch(&self) -> u64 {
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
}
/// The glyph for a character in the default font (FontSet.getGlyph).
pub fn glyph(&self, c: char) -> Glyph {
+91 -21
View File
@@ -122,6 +122,7 @@ mod moved_extras;
mod moving_pistons;
mod music;
mod names;
mod native_hooks;
mod native_shaders;
mod natural_spawning;
mod nautili;
@@ -1408,6 +1409,8 @@ struct Game<'a> {
/// The link a chat click asks to open (`ConfirmLinkScreen`).
link_prompt: Option<String>,
dialogs: dialogs::DialogState,
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
integrated_starting: Option<integrated::Starting>,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -2333,12 +2336,19 @@ impl Game<'_> {
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// Prefer the selected slot, then the hotbar, then the main inventory.
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
if let Some(slot) = &mut self.inventory[i]
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
@@ -5046,6 +5056,7 @@ impl Game<'_> {
};
let mut player = PlayerMotion::new(self.motion, feet)?;
player.yaw = self.yaw;
let entering = self.restore_player_state;
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
player.may_fly = self.creative || spectator;
@@ -5070,6 +5081,11 @@ impl Game<'_> {
self.capture(false);
}
self.previous_position = feet;
if entering {
// The world's first player: a server's client gets it with its
// level (Minecraft.setLevel), which stops every sound.
self.stop_sounds_for_new_level();
}
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
@@ -5142,6 +5158,7 @@ impl Game<'_> {
self.update_totem_ready();
self.tick_command_extras();
self.tick_mods();
self.native_tick();
self.tick_server_mods();
let camera_eye = self.camera_eye_height();
let stick = self.pad_move();
@@ -5684,6 +5701,16 @@ impl Game<'_> {
rotation.turn(xo * zoom, yo * zoom);
self.yaw = rotation.y_rot;
self.pitch = rotation.x_rot;
// Entity.turn ends in AbstractBoat.onPassengerTurned: clampRotation
// keeps the view within 105 degrees of the boat at every turn, not
// only at the tick, so it never swings past and back.
if let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
{
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
}
}
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
@@ -5838,30 +5865,31 @@ impl Game<'_> {
/// highlight (its count yellow when the slot caps it), and hides while
/// it is the drag's only slot.
fn draw_slot_item(
&mut self,
&self,
batch: &mut HudBatch,
drag: Option<&DragView>,
slot: usize,
stack: Option<ItemStack>,
stack: &Option<ItemStack>,
x: f32,
y: f32,
) {
let carried;
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
Some(None) => return,
Some(Some((count, capped))) => {
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
let carried = self
carried = self
.carried
.clone()
.map(|c| ItemStack { count: *count, ..c });
(carried, *capped)
(carried.as_ref(), *capped)
}
None => (stack, false),
None => (stack.as_ref(), false),
};
if let Some(stack) = stack {
self.draw_item_model(batch, &stack, x, y);
self.draw_item_model(batch, stack, x, y);
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
self.draw_item_decorations(batch, &stack, x, y, text);
self.draw_item_decorations(batch, stack, x, y, text);
}
}
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
@@ -6479,7 +6507,7 @@ impl Game<'_> {
);
}
let stack = self.slot_view(i);
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
}
fn draw_inventory(&mut self, batch: &mut HudBatch) {
@@ -8157,6 +8185,7 @@ impl Game<'_> {
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
@@ -8478,7 +8507,9 @@ impl Game<'_> {
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
if !m.alive() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit.
if !m.alive() || Some(m.id) == self.riding {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -11147,7 +11178,7 @@ impl Game<'_> {
let selected = self.creative_screen.selected;
// Only what is drawn of the tabs: their stacks stay where they are.
let tabs = self.creative_screen.tab_headers();
let tab = tabs[selected.min(tabs.len() - 1)].clone();
let tab = &tabs[selected.min(tabs.len() - 1)];
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
format!(
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
@@ -11183,8 +11214,8 @@ impl Game<'_> {
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
}
let (tx, ty) = Creative::tab_position(&tab);
batch.sprite(&self.gui.atlas, &tab_sprite(&tab, true), ox + tx, oy + ty);
let (tx, ty) = Creative::tab_position(tab);
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
for t in &tabs {
let (tx, ty) = Creative::tab_position(t);
let stack = ItemStack {
@@ -11202,7 +11233,7 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
if !tab.inventory {
let title = self.creative_tab_title(&tab);
let title = self.creative_tab_title(tab);
batch.text(
atlas,
&title,
@@ -11281,15 +11312,14 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let stack = self.inventory[c].clone();
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
if self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = stack;
hovered = self.inventory[c].clone();
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -11553,6 +11583,27 @@ impl Game<'_> {
Ok(())
}
fn render(&mut self) -> Result<bool> {
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
// server starts. Its chunk map and progress are the server's, which
// the window doesn't see yet: the title alone, over the menu background.
if self.integrated_starting.is_some() {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
let (w, h) = (hud.width, hud.height);
horizon_client::ui::menu_background(
&mut hud,
&self.gui.atlas,
0.0,
0.0,
w,
h,
false,
);
horizon_client::level_loading::draw(&mut hud, &self.gui.atlas, None, None);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
if let Some(loader) = &self.world_loader {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
@@ -11773,7 +11824,8 @@ impl Game<'_> {
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
let title = self.title.is_some()
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|| self.remote_loading();
let (yaw, pitch) = if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
(
@@ -12083,7 +12135,8 @@ impl Game<'_> {
/// `Screen.extractBlurredBackground` over the panorama or the world and
/// HUD, when `menuBackgroundBlurriness` is at least 1.
fn menu_blur(&self) -> Option<f32> {
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|| self.remote_loading();
let blurred = loading
|| self.title.as_ref().is_some_and(|t| {
matches!(self.screen, Screen::Options | Screen::Win)
@@ -12742,7 +12795,9 @@ impl ApplicationHandler for Game<'_> {
self.perf.cancel_world_opening();
return;
}
if (self.pending_entry.is_some() || self.world_loader.is_some())
if (self.pending_entry.is_some()
|| self.world_loader.is_some()
|| self.integrated_starting.is_some())
&& !matches!(
&event,
WindowEvent::CloseRequested
@@ -12889,6 +12944,7 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Playing
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& !self.mod_wheel(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
@@ -12961,6 +13017,12 @@ impl ApplicationHandler for Game<'_> {
self.dialog_click()?;
}
}
// ConnectScreen's Cancel, under the prompts and dialogs above.
WindowEvent::MouseInput { state, button, .. } if self.remote_loading() => {
if state == ElementState::Pressed && button == MouseButton::Left {
self.connect_screen_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -13361,6 +13423,11 @@ impl ApplicationHandler for Game<'_> {
}
self.save_title_options();
}
PhysicalKey::Code(KeyCode::F5) => {
if let Some(t) = self.title.as_mut() {
t.refresh_servers();
}
}
_ => self.title_edit(event.text.as_deref(), false),
}
}
@@ -13370,6 +13437,7 @@ impl ApplicationHandler for Game<'_> {
return Ok(());
};
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
&& event.state == ElementState::Pressed
@@ -13740,6 +13808,7 @@ impl<'a> Game<'a> {
server_packs: Default::default(),
link_prompt: None,
dialogs: Default::default(),
integrated_starting: None,
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
@@ -14214,6 +14283,7 @@ impl Game<'_> {
}
fn resume_world_entry(&mut self) -> Result<()> {
self.poll_integrated_start()?;
self.poll_world_rest()?;
if let Some(loader) = &self.world_loader {
if !loader.ready() {
+3
View File
@@ -151,6 +151,9 @@ impl Game<'_> {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
// BookViewScreen.handleClickEvent: the rest is
// Screen.defaultHandleGameClickEvent's.
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
_ => {}
}
}
+7 -7
View File
@@ -675,7 +675,7 @@ impl Game<'_> {
// unless a client mod owns the command.
if self.remote.is_some() {
if let Some(command) = message.strip_prefix('/')
&& self.mod_command(command)
&& self.client_mod_command(command)
{
return;
}
@@ -684,7 +684,7 @@ impl Game<'_> {
}
if let Some(command) = message.strip_prefix('/') {
// A client mod's command first, then a server mod's, as on a server.
if !self.mod_command(command) && !self.local_server_mod_command(command) {
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
self.run_command(command);
}
} else {
@@ -786,6 +786,9 @@ impl Game<'_> {
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
@@ -870,7 +873,7 @@ impl Game<'_> {
/// parse; here the server answers it.)
pub(super) fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
if self.client_mod_command(command) {
return;
}
if self.remote.is_some() {
@@ -1026,10 +1029,7 @@ impl Game<'_> {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
self.ui.difficulty = self.level.difficulty_id();
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
+4 -1
View File
@@ -91,7 +91,7 @@ fn format_double(v: f64) -> String {
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.mod_command(command) {
if self.client_mod_command(command) {
return true;
}
let d = vanilla::game_dispatcher();
@@ -2573,6 +2573,9 @@ impl Game<'_> {
)));
}
self.ui.difficulty = i;
// The level data holds the world's difficulty: it survives the
// reopen, so it follows the command.
self.level.set_difficulty(i);
self.sync_rules();
if i == 0 {
// Peaceful removes hostile mobs.
@@ -343,8 +343,10 @@ impl Game<'_> {
// hook does.
let action = arg(2)?;
self.mod_key(physical, action != "up", action == "repeat");
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.mod_key(physical, false, false);
self.native_key_event(physical, false, false);
}
// Text screens consume raw presses; releases follow key binds.
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
+67 -8
View File
@@ -469,6 +469,7 @@ impl Game<'_> {
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
@@ -1258,14 +1259,18 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
MobEvent::ExplosionSource { mob, charged, name } => {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
@@ -1276,7 +1281,7 @@ impl Game<'_> {
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
if !noted && let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
@@ -4196,7 +4201,7 @@ impl Game<'_> {
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
@@ -4215,11 +4220,7 @@ impl Game<'_> {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
@@ -4575,6 +4576,63 @@ impl Game<'_> {
self.wind_charge_geometry(a, c, packed, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
std::sync::OnceLock::new();
let model = BULLET.get_or_init(|| {
horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:shulker_bullet#main",
)
});
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core.
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
@@ -4697,6 +4755,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
@@ -131,6 +131,8 @@ impl Game<'_> {
map.insert("item".into(), save::item_to_nbt(item));
}
spill::put_saved_entity(&mut self.world, pos, tag);
// The sherds and item go to the guests (BlockEntityData).
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
self.consume_held();
self.audio.play(
"minecraft:block.decorated_pot.insert",
+1 -1
View File
@@ -163,7 +163,7 @@ impl Game<'_> {
}
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
fn show_dialog_ref(&mut self, dialog: &Tag) {
pub(super) fn show_dialog_ref(&mut self, dialog: &Tag) {
match dialog {
Tag::String(id) => {
let id = id.to_string_lossy();
+29 -17
View File
@@ -2,7 +2,7 @@
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
//! Each hook does nothing while its extra is off and no mod runs.
use super::{Dim, Game, Screen};
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, fov_target},
extras_hud::{self, Corners},
@@ -36,14 +36,15 @@ impl Game<'_> {
}
/// Mouse buttons while playing, for mods' clicks.
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
self.native_mouse_event(button, pressed);
let i = match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
_ => return,
};
self.extras.mouse[i] = pressed;
// For mods (`input.clicks-per-second`).
if pressed && self.mods.running() {
// For mods (`input.clicks-per-second`, `native_clicks`).
if pressed {
let now = self.extras_now();
self.extras.cps[i].click(now);
}
@@ -57,22 +58,17 @@ impl Game<'_> {
self.extras.mouse = [false; 2];
}
self.update_minimap();
// For mods (`render.frame-stats`).
if self.mods.running() {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
} else {
self.extras.fps.clear();
}
// For mods (`render.frame-stats`, `native_frame_rate`).
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
self.native_frame(elapsed);
}
/// The frame counter for mods: fps, min, 1% low.
#[cfg(feature = "mods")]
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
let r = self.extras.fps.reading();
(r.fps, r.min, r.low)
}
/// Attack and use clicks in the last second, and whether each is held.
#[cfg(feature = "mods")]
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
let now = self.extras_now();
let [l, r] = &mut self.extras.cps;
@@ -135,7 +131,7 @@ impl Game<'_> {
if self.title.is_some() || self.ui.hide_gui {
return None;
}
let m = self.mod_minimap()?;
let m = self.minimap_wanted()?;
let view = View {
x: p.position.x,
z: p.position.z,
@@ -146,11 +142,26 @@ impl Game<'_> {
};
Some((m.corner, m.size, view))
}
/// The minimap shown: a native mod's (`native_minimap`), else a
/// WebAssembly mod's.
fn minimap_wanted(&self) -> Option<NativeMinimap> {
if let Some(m) = self.native_minimap_now() {
return Some(m);
}
let m = self.mod_minimap()?;
Some(NativeMinimap {
corner: m.corner,
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
}
/// Refresh the minimap's tiles within the frame budget, recompose its
/// image when the view or a tile changed, and queue the upload.
fn update_minimap(&mut self) {
let Some((_, _, view)) = self.minimap_shown() else {
if self.mod_minimap().is_none() || self.player.is_none() {
if self.minimap_wanted().is_none() || self.player.is_none() {
self.extras.minimap.clear();
self.extras.minimap_dim = None;
}
@@ -201,6 +212,9 @@ impl Game<'_> {
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
/// scale); none is 1.
fn zoom_now(&self) -> (f32, bool, f64) {
if let Some(z) = self.native_zoom_now() {
return (z.factor, z.smooth, f64::from(z.turn));
}
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
(z.factor, z.smooth, f64::from(z.turn))
})
@@ -228,9 +242,6 @@ impl Game<'_> {
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let mod_panels = self.mods.has_panels();
let map = self.minimap_shown();
if map.is_none() && !mod_panels {
return;
}
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
let atlas = &self.gui.atlas;
@@ -256,6 +267,7 @@ impl Game<'_> {
if mod_panels {
self.draw_mod_panels(batch, &mut corners);
}
self.native_hud(batch, &mut corners);
}
/// The height the status effect icons take at the top right
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
+52 -9
View File
@@ -169,6 +169,16 @@ enum Publish {
/// of the connection and the world's name, or why it failed.
type Ready = std::result::Result<(Pipe, String), String>;
/// An integrated server loading its world: the window keeps drawing its
/// loading screen and joins once the server is ready, as vanilla's client
/// does while its server prepares (`Minecraft.doWorldLoad`), instead of
/// waiting for it frozen for the whole load.
pub(super) struct Starting {
handle: Integrated,
ready: Receiver<Ready>,
player: String,
}
/// The world to open and the player who owns it.
struct Launch {
folder: String,
@@ -197,8 +207,8 @@ impl Game<'_> {
.as_ref()
.is_some_and(|s| s.published().is_some())
}
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
/// until it can be joined, then join it through memory.
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
/// window joins it through memory once it is ready (`poll_integrated_start`).
pub(super) fn open_integrated(
&mut self,
folder: &str,
@@ -267,20 +277,53 @@ impl Game<'_> {
let _ = ready_tx.send(Err(e.to_string()));
}
})?;
let mut handle = Integrated {
let handle = Integrated {
flags,
thread: Some(thread),
tasks,
world_name: String::new(),
};
let pipe = match ready.recv() {
Ok(Ok((pipe, world_name))) => {
handle.world_name = world_name;
pipe
self.integrated_starting = Some(Starting {
handle,
ready,
player,
});
// Harnesses, control scripts and runs without a window go on with the world open.
if self.window.is_none() || super::synchronous_startup(&self.options) {
while self.integrated_starting.is_some() {
self.poll_integrated_start()?;
if self.integrated_starting.is_some() {
std::thread::sleep(Duration::from_millis(5));
}
}
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err("the integrated server stopped".into()),
}
Ok(())
}
/// The starting server's answer, polled each frame: joined once it is ready.
pub(super) fn poll_integrated_start(&mut self) -> Result<()> {
let Some(starting) = &self.integrated_starting else {
return Ok(());
};
let (pipe, world_name) = match starting.ready.try_recv() {
Ok(Ok(ready)) => ready,
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
Ok(Err(e)) => {
self.integrated_starting = None;
return Err(e.into());
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.integrated_starting = None;
return Err("the integrated server stopped".into());
}
};
let Some(Starting {
mut handle, player, ..
}) = self.integrated_starting.take()
else {
return Ok(());
};
handle.world_name = world_name;
self.connect_local(pipe, &player)?;
self.integrated = Some(handle);
Ok(())
+29
View File
@@ -5,6 +5,7 @@
use super::Game;
use horizon_client::{
containers::{BlockEntity, Jukebox},
first_person::Hand,
items::ItemStack,
mobs::Species,
particles::{ParticleOptions, ParticleType},
@@ -64,6 +65,10 @@ impl Game<'_> {
return false;
}
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
}
self.eject_record(pos);
return true;
}
@@ -100,6 +105,30 @@ impl Game<'_> {
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `JukeboxBlock.useItemOn` on this client's level for the main hand:
/// whether the jukebox at `pos` takes the click (`has_record` pops the
/// disc out, or a `jukebox_playable` item goes in). The server does the
/// rest, see `use_jukebox`.
pub(super) fn jukebox_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]);
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
return false;
}
let has_record = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("has_record"))
.is_some_and(|v| v == "true");
has_record
|| held.is_some_and(|stack| {
self.res
.items()
.get(&stack.item)
.is_some_and(|i| i.jukebox_song.is_some())
})
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
self.stop_sound(pos);
+8
View File
@@ -2040,6 +2040,14 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g))
.map(|g| (g.chunk(), radius))
.chain(
self.arrival_chunks(self.dim)
.into_iter()
// An arrival keeps only its landing spot lit enough to
// resolve (the 3x3 `resolve_arrivals` checks), where it
// then ticks without a player.
.map(|chunk| (chunk, 1)),
)
.collect();
}
+39 -3
View File
@@ -1038,12 +1038,23 @@ impl Game<'_> {
if spectator {
return;
}
if !host.hub.guests[gi].may_reach_block(a.pos) {
return;
}
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
// under spawn protection (the ack puts the block back).
if self.under_spawn_protection(host, gi, a.pos) {
self.spawn_protection_message(host, conn, a.pos);
return;
}
// mayInteract: a block outside the world border can't be
// dug (the ack puts it back on the client).
if !self
.border
.contains(f64::from(a.pos[0]), f64::from(a.pos[2]))
{
return;
}
let face = direction(i32::from(a.direction));
let play = host.play.entry(conn).or_default();
play.digging = Some((a.pos, face));
@@ -1070,6 +1081,15 @@ impl Game<'_> {
1 => {}
3 => {
host.hub.ack(conn, a.sequence);
if !host.hub.guests[gi].may_reach_block(a.pos) {
// Too far (handleBlockBreakAction's "too far"): the
// dig drops and the ack has the client undo its
// prediction.
let play = host.play.entry(conn).or_default();
play.digging = None;
play.mining = None;
return;
}
self.guest_stop_digging(host, conn, a.pos);
}
2 => {
@@ -1162,6 +1182,14 @@ impl Game<'_> {
if spectator {
return;
}
// handleUseItemOn: within reach, and the hit point within the block.
let inside = p
.cursor
.iter()
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
return;
}
let face = direction(p.face);
let hit = Hit {
pos: p.pos,
@@ -1175,9 +1203,14 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
// handleUseItemOn: under spawn protection nothing is used or
// placed; the player is told and the blocks below go back.
// mayInteract: nothing is used on a block outside the world
// border either.
if self.under_spawn_protection(host, gi, p.pos) {
self.spawn_protection_message(host, conn, p.pos);
} else {
} else if self
.border
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
{
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
@@ -1384,6 +1417,9 @@ impl Game<'_> {
} else {
p.difficulty as u8
};
// The level data holds the world's difficulty: it
// survives the reopen, so it follows the change.
self.level.set_difficulty(self.ui.difficulty);
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
@@ -3041,10 +3077,10 @@ impl Game<'_> {
Some(_) => {}
}
}
// Entity.take: the clients play the pickup sound themselves
// (handleTakeItemEntity).
for (item, count) in taken {
host.hub.take_item(item as i32, id, count as i32, position);
host.hub
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
}
}
@@ -90,6 +90,14 @@ impl Game<'_> {
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
})
}
/// Whether a server player rides `vehicle` (`Entity.isPassenger`: a
/// passenger does not use a portal on its own, the vehicle carries it
/// across, `Entity.teleportCrossDimension`).
pub(super) fn guest_rides(&self, vehicle: u32) -> bool {
self.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(vehicle)))
}
/// `Player.rideTick` for a server player, before its moves this tick:
/// it stays in its seat, steers its minecart, or gets out.
+38 -4
View File
@@ -52,8 +52,8 @@ pub(super) struct Parked {
armor_stands: Vec<horizon_client::entities::ArmorStand>,
cushions: Vec<horizon_client::entities::Cushion>,
others: Vec<commands::OtherEntity>,
boats: Vec<boats::Boat>,
carts: Vec<minecarts::Minecart>,
pub(super) boats: Vec<boats::Boat>,
pub(super) carts: Vec<minecarts::Minecart>,
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
crystals: Vec<dragon::Crystal>,
@@ -65,7 +65,7 @@ pub(super) struct Parked {
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
mobs: Vec<Mob>,
pub(super) mobs: Vec<Mob>,
/// The entity ids of the level's fence knots (`knot_ids`).
knot_ids: HashMap<[i32; 3], u32>,
projectiles: Vec<horizon_client::projectiles::Projectile>,
@@ -122,6 +122,12 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
@@ -297,6 +303,23 @@ impl Game<'_> {
Ok(())
}
/// The entity's memory (its UUID and the compound it was loaded with)
/// moves with it from the level not in place to the level in place, so
/// it is saved in the other level as it was.
pub(super) fn move_entity_memory(&mut self, from: Dim, id: u32) {
let Some(p) = self.levels.parked.get_mut(&from) else {
return;
};
let uuid = p.persist.uuids.remove(&id);
let raw = p.persist.raw.remove(&id);
if let Some(u) = uuid {
self.persist.uuids.insert(id, u);
}
if let Some(r) = raw {
self.persist.raw.insert(id, r);
}
}
/// Put `dim`'s level in place for a command whose source is there
/// (`execute in`, `execute at` a player in another level), opening it if
/// nobody is in it; returns the level to put back afterwards. Only a
@@ -442,6 +465,9 @@ impl Game<'_> {
guests.iter().any(|g| g.dimension == d.id())
|| forced
|| self.pregen_level() == Some(*d)
// A pending portal arrival keeps its destination level
// running so it can land and tick (the portal ticket).
|| !self.arrival_chunks(*d).is_empty()
})
.collect();
if dims.is_empty() {
@@ -454,9 +480,17 @@ impl Game<'_> {
/// (other than the Overworld) nobody has been in for a while.
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
let occupied = self.levels_to_tick();
// A vehicle waiting there for its rider's arrival in another level.
let waiting: Vec<Dim> = self
.levels
.parked
.keys()
.copied()
.filter(|d| self.vehicle_waits_in(*d))
.collect();
let mut closing = Vec::new();
for (dim, p) in &mut self.levels.parked {
if occupied.contains(dim) {
if occupied.contains(dim) || waiting.contains(dim) {
p.idle = 0;
continue;
}
@@ -1459,6 +1459,7 @@ impl Game<'_> {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
self.res
.block_entity_render()
+11 -1
View File
@@ -267,6 +267,7 @@ impl Game<'_> {
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
);
}
self.list_native_mods();
}
pub(super) fn merge_remote_tree(&mut self) {}
pub(super) fn sync_mods(&mut self) {}
@@ -302,8 +303,14 @@ impl Game<'_> {
.filter_map(|p| horizon_mods::Package::read(p).ok())
.map(|p| p.manifest.id)
.collect();
// A native mod of the same id (one built into this game) takes over.
let mut native = Vec::new();
self.native_mods(&mut native);
let enabled = &self.settings.mods.enabled;
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let on = |id: &str| {
(enabled.contains(id) || dev_ids.iter().any(|d| d == id))
&& !native.iter().any(|n| n.id == id)
};
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
@@ -349,6 +356,7 @@ impl Game<'_> {
problem,
dev: false,
has_settings: false,
native: None,
};
};
// What it may do first: the row cuts long descriptions.
@@ -365,9 +373,11 @@ impl Game<'_> {
problem,
dev: dev.contains(&e.path),
has_settings: e.has_ui_settings(),
native: None,
}
})
.collect();
self.list_native_mods();
}
/// The chat's tree after the running mods changed.
fn sync_mod_commands(&mut self) {
+16 -2
View File
@@ -45,14 +45,28 @@ impl Game<'_> {
};
self.audio.tick(self.pause, &ctx);
}
/// `Minecraft.setLevel` -> `updateLevelInEngines`: `SoundManager.stop()`.
/// The music manager keeps its stopped track and, on its next tick, waits
/// the level's music delay before the next one.
pub(super) fn stop_sounds_for_new_level(&mut self) {
self.ambience.clear(&mut self.audio);
self.audio.stop_all();
}
/// `getSituationalMusic` and `getMusicVolume`.
fn situational_music(&self) -> (Option<Music>, f32) {
pub(super) fn situational_music(&self) -> (Option<Music>, f32) {
let empty = BackgroundMusic::default();
// The title screen has no player (and no level for the camera probe).
// A server's client has its player from handleLogin on, before the
// first teleport makes it here: the camera is still at the origin.
let eye = if self.title.is_some() {
None
} else {
self.eye_position()
self.eye_position().or_else(|| {
self.remote
.as_ref()
.is_some_and(|r| r.logged_in)
.then_some([0.0; 3])
})
};
let (background, probe_volume) = match eye {
Some(eye) => {
+7 -4
View File
@@ -81,10 +81,13 @@ impl Game<'_> {
let viewer = self.player_team();
let mut tags = Vec::new();
let players = self.other_players();
let named = self
.mobs
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let named = self.mobs.iter().filter_map(|m| {
let name = m.custom_name.as_ref()?;
// The tag's 64 block distance is measured to the entity: copy the
// name only for a mob the frame can show it on.
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
});
let players = players.into_iter().map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
@@ -0,0 +1,401 @@
//! Hook points for native mods (docs/native-mods.md, "Hook points"): empty
//! functions the game calls at fixed places, for horizon-mixins bundles to
//! hook with HEAD. Hooks that answer take an out parameter (`claimed`,
//! `zoom`…) so several mods can hook the same point. A native mod keeps its
//! own state in a module of its bundle; the helpers below read and draw what
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! Names, parameters and call sites are kept from release to release, and
//! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`].
//! A bundle that hooks only these names the API it needs (`hooks`), and the
//! launcher builds it on any Horizon whose API is at least that one. A change
//! that would break such a bundle is not made.
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::{Game, Screen, execution_role::ExecutionRole};
use horizon_client::{
chat::ChatSource,
extras::Corner,
extras_hud::Corners,
hud::{HudBatch, rgba},
keybinds,
mod_list::{self, Installed, NativePage},
text::Text,
};
use std::{fs, path::PathBuf, time::Duration};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 1;
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
/// eased through the field of view like the spyglass when `smooth`, and the
/// scale of mouse turning.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeZoom {
pub(crate) factor: f32,
pub(crate) smooth: bool,
pub(crate) turn: f32,
}
/// The minimap a native mod shows (`native_minimap`), drawn by Horizon's
/// minimap: its corner, its side in GUI pixels, blocks per map pixel,
/// turning with the player or north up, round or square.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeMinimap {
pub(crate) corner: Corner,
pub(crate) size: f32,
pub(crate) blocks_per_pixel: f32,
pub(crate) rotate: bool,
pub(crate) round: bool,
}
/// The player as a native mod reads it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativePlayer {
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) z: f64,
/// Degrees; 0 faces south, 90 west (`Direction.fromYRot`).
pub(crate) yaw: f32,
pub(crate) pitch: f32,
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub(crate) dimension: &'static str,
}
/// A native mod as Options > Mods lists it (`native_mods`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeMod {
/// Its id: its settings folder and its keys' lines.
pub(crate) id: String,
/// `Name 1.0`.
pub(crate) title: String,
pub(crate) description: String,
/// Its keys: name, label and default key (`key.keyboard.z`). The page
/// lists them first; `native_mod_key` reads the player's choice.
pub(crate) keys: Vec<[String; 3]>,
}
/// The mappings `native_mapping` answers for, with the key their default
/// binding is (none: a mouse button, attack or use).
#[allow(dead_code)]
const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
("key.forward", Some(KeyCode::KeyW)),
("key.left", Some(KeyCode::KeyA)),
("key.back", Some(KeyCode::KeyS)),
("key.right", Some(KeyCode::KeyD)),
("key.jump", Some(KeyCode::Space)),
("key.sneak", Some(KeyCode::ShiftLeft)),
("key.sprint", Some(KeyCode::ControlLeft)),
("key.attack", None),
("key.use", None),
];
/// The hook points. Each does nothing in Horizon.
impl Game<'_> {
/// Once a frame, in a world or not, after Horizon's extras.
pub(super) fn native_frame(&mut self, elapsed: Duration) {
let _ = elapsed;
}
/// Once a tick, in every `Game`: the window's (in single player, on LAN
/// and on servers), and the server's own (`native_window` is false
/// there: the single player server thread, a dedicated server).
pub(super) fn native_tick(&mut self) {}
/// The HUD (not drawn under F1), after the minimap: place a box with
/// `corners.place` (boxes stack away from their edge) and draw it with
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
/// says is taken.
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
let _ = (batch, corners);
}
/// A key or mouse button went down or up, by its vanilla name
/// (`key.keyboard.z`, `key.mouse.left`): keys on any screen (check
/// `native_playing`), mouse buttons while playing. Repeats are left out.
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
let _ = (key, pressed);
}
/// Wheel steps while playing: set `claimed` to keep them from the hotbar.
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
let _ = (steps, claimed);
}
/// A command typed or clicked in chat, without its slash, before the
/// server and Horizon's commands see it: set `ran` when the mod ran it.
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
let _ = (command, ran);
}
/// The zoom a native mod asks for, applied while playing (the spyglass's
/// own view wins): set `zoom` when it is none yet.
pub(super) fn native_zoom(&self, zoom: &mut Option<NativeZoom>) {
let _ = zoom;
}
/// The minimap a native mod shows: set `minimap` when it is none yet.
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
let _ = minimap;
}
/// Options > Mods lists the native mods: push yours.
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
let _ = mods;
}
/// The buttons of mod `mod_id`'s settings page, under its keys: push
/// their labels when it is yours. Asked again after each click.
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
let _ = (mod_id, buttons);
}
/// Button `button` of mod `mod_id`'s settings page was clicked.
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
let _ = (mod_id, button);
}
}
/// Helpers for native mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
/// turned off does nothing (check this first in each hook) until the
/// launcher's next build leaves it out.
pub(super) fn native_on(&self, mod_id: &str) -> bool {
!self.settings.mods.disabled.contains(mod_id)
}
/// Playing, with nothing over the world: keys and zoom apply.
pub(super) fn native_playing(&self) -> bool {
self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none()
}
/// This `Game` is the window's, not a server's.
pub(super) fn native_window(&self) -> bool {
self.options.execution_role == ExecutionRole::Client
}
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
!self.debug || corner != Corner::TopLeft
}
/// The width of `text` in GUI pixels.
pub(super) fn native_text_width(&self, text: &str) -> f32 {
HudBatch::text_width(&self.gui.atlas, text)
}
/// A rectangle in GUI pixels, `argb` (0x78000000 is the extras' box).
pub(super) fn native_fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
let [x, y, w, h] = rect;
let [a, r, g, b] = argb.to_be_bytes();
batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a));
}
/// A line of text at `(x, y)` in GUI pixels, opaque `rgb`.
pub(super) fn native_text(
&self,
batch: &mut HudBatch,
text: &str,
at: [f32; 2],
rgb: u32,
shadow: bool,
) {
let [x, y] = at;
let [_, r, g, b] = rgb.to_be_bytes();
let color = rgba(r, g, b, 0xFF);
batch.text_styled(&self.gui.atlas, &Text::literal(text), x, y, color, shadow);
}
/// The player, in a world.
pub(super) fn native_player(&self) -> Option<NativePlayer> {
let p = self.player.as_ref()?;
Some(NativePlayer {
x: p.position.x,
y: p.position.y,
z: p.position.z,
yaw: self.yaw,
pitch: self.pitch,
dimension: self.dim.id(),
})
}
/// The frame counter: frames in the last second, the slowest frame and
/// the 1% low of the last 5 seconds, in frames per second.
pub(super) fn native_frame_rate(&self) -> (u32, u32, u32) {
self.frame_reading()
}
/// Attack and use clicks in the last second.
pub(super) fn native_clicks(&mut self) -> (u32, u32) {
self.mouse_reading().0
}
/// A vanilla mapping (`key.forward`, `key.jump`, `key.attack`…): whether
/// it is held, and its key's label as Controls shows it.
pub(super) fn native_mapping(&mut self, name: &str) -> Option<(bool, String)> {
let &(_, key) = MAPPINGS.iter().find(|m| m.0 == name)?;
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
let down = match key {
Some(k) => self.keys.contains(&k),
None => self.mouse_reading().1[usize::from(name == "key.use")],
};
Some((down, label))
}
/// The key the player chose for a mod's key `name`, else `default`
/// (`key.keyboard.z`), as the WebAssembly mods' keys were kept
/// (`key.<mod id>.<name>` in `horizon-mods.txt`).
pub(super) fn native_mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
let chosen = self
.settings
.mods
.keys
.get(mod_id)
.and_then(|k| k.get(name));
chosen.map_or(default, String::as_str).to_owned()
}
/// A line in chat from the client.
pub(super) fn native_chat(&mut self, text: &str) {
let focused = self.screen == Screen::Chat;
let line = Text::literal(text);
let atlas = &self.gui.atlas;
self.ui
.chat
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
}
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
/// where the WebAssembly mod of the same id kept its storage, so a
/// player's settings carry over. Not created here.
pub(super) fn native_data_dir(&self, mod_id: &str) -> PathBuf {
let mods = Self::options_path().with_file_name(mod_list::DIR);
mods.join(".horizon-mod-data").join(mod_id)
}
/// A mod's setting `key` (one file per key in `native_data_dir`).
pub(super) fn native_setting(&self, mod_id: &str, key: &str) -> Option<String> {
let path = self.native_data_dir(mod_id).join(key);
fs::read_to_string(path).ok().map(|v| v.trim().to_owned())
}
/// Keeps a mod's setting `key`.
pub(super) fn native_set_setting(&self, mod_id: &str, key: &str, value: &str) {
let dir = self.native_data_dir(mod_id);
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
}
}
/// Where the game calls the hook points.
impl Game<'_> {
/// Options > Mods' rows for the native mods, after the others.
pub(super) fn list_native_mods(&mut self) {
let mut mods = Vec::new();
self.native_mods(&mut mods);
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
// native row stands for it, and alone toggles its `mod.<id>` line.
self.settings
.mods
.installed
.retain(|row| row.native.is_none() && !mods.iter().any(|m| m.id == row.id));
for m in mods {
let mut buttons = Vec::new();
self.native_settings(&m.id, &mut buttons);
let page = NativePage {
keys: m.keys,
buttons,
};
self.settings.mods.installed.push(Installed {
id: m.id,
title: m.title,
description: m.description,
problem: None,
dev: false,
has_settings: false,
native: Some(page),
});
}
}
/// A click on button `button` of native mod `i`'s page (its index in
/// Options > Mods), and the page's labels after it.
pub(super) fn native_settings_event(&mut self, i: usize, button: usize) {
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
return;
};
self.native_settings_click(&id, button);
let mut buttons = Vec::new();
self.native_settings(&id, &mut buttons);
let row = self.settings.mods.installed.get_mut(i);
if let Some(page) = row.and_then(|m| m.native.as_mut()) {
page.buttons = buttons;
}
}
/// A key event for `native_key`.
pub(super) fn native_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if repeat {
return;
}
if let Some(name) = keybinds::key_name(key) {
self.native_key(name, pressed);
}
}
/// A mouse button while playing, for `native_key`.
pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) {
let name = keybinds::mouse_name(button);
self.native_key(&name, pressed);
}
/// Wheel steps while playing: whether a native mod took them.
pub(super) fn native_wheel_event(&mut self, steps: f32) -> bool {
let mut claimed = false;
self.native_wheel(steps, &mut claimed);
claimed
}
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
let mut ran = false;
self.native_command(command, &mut ran);
ran || self.mod_command(command)
}
/// The zoom in effect while playing (a native mod's); the spyglass's own
/// view wins.
pub(super) fn native_zoom_now(&self) -> Option<NativeZoom> {
if !self.native_playing() || self.scoping() {
return None;
}
let mut zoom = None;
self.native_zoom(&mut zoom);
// As the WebAssembly runtime clamped a mod's zoom.
let z = zoom?;
let factor = finite(z.factor, 1.0, 10.0);
(factor > 1.0).then_some(NativeZoom {
factor,
smooth: z.smooth,
turn: if z.turn.is_finite() {
z.turn.clamp(0.05, 1.0)
} else {
1.0
},
})
}
/// The minimap a native mod shows.
pub(super) fn native_minimap_now(&self) -> Option<NativeMinimap> {
let mut minimap = None;
self.native_minimap(&mut minimap);
minimap.map(|m| NativeMinimap {
size: finite(m.size, 16.0, 256.0),
blocks_per_pixel: finite(m.blocks_per_pixel, 0.5, 4.0),
..m
})
}
}
/// `v` within `lo..=hi`; `lo` when it is not a number.
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { lo }
}
#[cfg(test)]
mod tests {
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
/// the hook points and helpers must stay free of both. The launcher
/// reads the API line as it is.
#[test]
fn hook_points_stay_hookable() {
let source = include_str!("native_hooks.rs");
let code = source.split("#[cfg(test)]").next().unwrap_or_default();
assert!(code.contains("\npub(crate) const NATIVE_HOOKS_API: u32 = "));
let lines = code.lines().filter(|l| !l.trim_start().starts_with("//"));
for line in lines {
assert!(!line.contains("cfg"), "a cfg in the hook points: {line}");
for mac in ["!(", "![", "! {", "!{"] {
assert!(!line.contains(mac), "a macro in the hook points: {line}");
}
}
}
}
@@ -44,7 +44,7 @@ pub(super) struct Plan {
height: f32,
/// Through an End portal: `approximate` is the End's spawn point, or the
/// Overworld's shared spawn.
end: bool,
pub(super) end: bool,
}
impl Plan {
@@ -91,6 +91,10 @@ pub(super) struct Portals {
/// Positions before this tick's moves, by entity id.
before: HashMap<u32, Vec3>,
arrivals: Vec<Arrival>,
/// The vehicles server players ride that went through a portal this
/// tick, in their level, with their entry: they carry their riders
/// (`tick_server_travel`).
carrying: Vec<(Dim, u32, BlockPos)>,
loaded: bool,
dirty: bool,
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
@@ -628,7 +632,7 @@ impl Game<'_> {
.iter()
.map(|b| b.id)
.chain(self.carts.iter().map(|c| c.id))
.map(|id| (id, !self.vehicle_occupied(id))),
.map(|id| (id, self.vehicle_can_use_portal(id))),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
@@ -645,7 +649,13 @@ impl Game<'_> {
}
}
for (id, entry) in leaving {
self.send_through_portal(id, entry);
// `Entity.teleportCrossDimension` carries the passengers: a
// vehicle server players ride takes them along.
if self.guest_rides(id) {
self.portals.carrying.push((self.dim, id, entry));
} else {
self.send_through_portal(id, entry);
}
}
}
/// `Entity.load`: the saved `PortalCooldown` (an entity that just came
@@ -681,8 +691,51 @@ impl Game<'_> {
m.alive()
&& m.extra.mount.is_none()
&& self.riding != Some(m.id)
&& (!self.guest_rides(m.id) || self.carries_riders())
&& m.species() != Species::Wither
}
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
/// server players only, whom it carries along.
fn vehicle_can_use_portal(&self, id: u32) -> bool {
if !self.vehicle_occupied(id) {
return true;
}
self.carries_riders()
&& self.riding != Some(id)
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// Whether a vehicle can take the server players riding it through a
/// portal: only a server moves players between its levels. Elsewhere a
/// ridden vehicle stays, rather than leave its rider behind.
fn carries_riders(&self) -> bool {
self.options.dedicated.is_some()
}
/// The vehicles that went through a portal in `dim` with server players
/// on them.
pub(super) fn take_carrying(&mut self, dim: Dim) -> Vec<(u32, BlockPos)> {
let (here, other) = std::mem::take(&mut self.portals.carrying)
.into_iter()
.partition(|(d, ..)| *d == dim);
self.portals.carrying = other;
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
}
/// The plan of vehicle `id` through the portal at `entry`: from its own
/// position and size (`getPortalDestination(level, vehicle)`).
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
}
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
return Some(self.entity_plan(entry, b.position, width, height, false));
}
let c = self.carts.iter().find(|c| c.id == id)?;
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
Some(self.entity_plan(entry, c.position, width, height, false))
}
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
@@ -721,7 +774,7 @@ impl Game<'_> {
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| !self.vehicle_occupied(*id))
.filter(|(id, ..)| self.vehicle_can_use_portal(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
@@ -920,6 +973,18 @@ impl Game<'_> {
self.portals.dirty = true;
}
}
/// The chunks of the arrivals still waiting to land in `dim`, for the
/// streaming `extra` areas that light them: vanilla's portal ticket
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
/// the area loaded without a player.
pub(super) fn arrival_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
self.portals
.arrivals
.iter()
.filter(|a| a.plan.dim == dim)
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
.collect()
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
/// their exit portal (found or built) or End destination, position,
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
@@ -246,6 +246,7 @@ impl Game<'_> {
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::NativeButton(i, b) => self.native_settings_event(i, b),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
}
+8
View File
@@ -45,6 +45,14 @@ pub fn open_folder(dir: &Path) {
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
// URLs need Windows' registered protocol handler, not Explorer's path
// handling. ShellExecute also preserves the URL as one Unicode value,
// without interpolating its query string into a command interpreter.
#[cfg(windows)]
if let Err(error) = open::that_detached(uri) {
log::warn!("Horizon: could not open URI {uri}: {error}");
}
#[cfg(not(windows))]
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
+135 -28
View File
@@ -14,7 +14,7 @@ use horizon_client::{
chunk::Chunk,
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
hud::{HudBatch, rgba},
hud::HudBatch,
items::ItemStack,
menu::Input,
mobs::{Mob, MobType, Who},
@@ -115,6 +115,9 @@ pub(super) struct Remote {
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
joined: bool,
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
/// while here the player is made at the first teleport.
pub(super) logged_in: bool,
status: String,
pub(super) entity_id: i32,
/// The item the player's use began with (`LivingEntity.useItem`).
@@ -303,6 +306,7 @@ impl Game<'_> {
name: &str,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
super::world_loading::preload_server_loot(&self.options.resources);
if self.defer_world_entry(super::PendingWorldEntry::Server {
address: address.to_owned(),
name: name.to_owned(),
@@ -453,6 +457,7 @@ impl Game<'_> {
lang,
registries: Arc::new(Dynamic::default()),
joined: false,
logged_in: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
@@ -1046,6 +1051,8 @@ impl Game<'_> {
.as_ref()
.map(|d| (d.dimension.clone(), d.pos));
if !same {
// A new ClientLevel: Minecraft.setLevel stops every sound.
self.stop_sounds_for_new_level();
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
@@ -1148,6 +1155,23 @@ impl Game<'_> {
else {
return;
};
// A decorated pot is no block entity here: its tag is kept with the chunk.
if name == "minecraft:decorated_pot" {
if let Tag::Compound(fields) = tag
&& !fields.is_empty()
{
let mut full = fields.clone();
full.insert("id".into(), Tag::String(name.into()));
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
// The section is drawn again with the pot's sherds.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
}
return;
}
let Some(entity) = self.world.block_entities.get(&key) else {
return;
};
@@ -1162,7 +1186,22 @@ impl Game<'_> {
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
if let Some((_, mut loaded)) =
horizon_client::save::decode_block_entity(&Tag::Compound(full))
{
// The turn of a vault's item and a trial spawner's mob is the client's
// own, not in the tag: an update keeps it (VaultClientData).
match (self.world.block_entities.get(&key), &mut loaded) {
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
_ => {}
}
self.world.block_entities.insert(key, loaded);
// The section is drawn again with what the block entity shows.
let state = self.world.block(pos[0], pos[1], pos[2]);
@@ -1733,6 +1772,7 @@ impl Game<'_> {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.logged_in = true;
r.view_distance = p.chunk_radius;
r.show_death_screen = p.show_death_screen;
}
@@ -1753,7 +1793,12 @@ impl Game<'_> {
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
self.enter_remote_dimension(&p.spawn, true);
if dimension_changed {
let (music, _) = self.situational_music();
self.music.stop_playing(music.as_ref(), &mut self.audio);
}
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
r.death_message = None;
@@ -2234,21 +2279,30 @@ impl Game<'_> {
}
}
C::TakeItemEntity(p) => {
// An orb's sound comes from the server, at the orb.
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
// the item, whoever collects it (the server sends none). An
// orb's sound comes from the server, at the orb; an arrow this
// client does not track as an item sounds at this player.
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
if !orb
&& self
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
let at = match item {
Some(d) => Some([d.position.x, d.position.y, d.position.z]),
None if self
.remote
.as_ref()
.is_some_and(|r| r.entity_id == p.player)
&& let Some(eye) = self.eye_position()
{
.is_some_and(|r| r.entity_id == p.player) =>
{
self.eye_position()
}
None => None,
};
if !orb && let Some(at) = at {
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
}
// ItemPickupParticle toward the collector (this player, another one or a mob).
let collector = if self
@@ -2349,6 +2403,17 @@ impl Game<'_> {
{
m.health = 0.0;
}
// LivingEntity.handleEntityEvent 3 on this player: its death
// sound, which Player.playSound plays for this client alone
// (the server's goes to everyone else).
if p.event == 3
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
&& let Some(pl) = self.player.as_ref()
{
let at = [pl.position.x, pl.position.y, pl.position.z];
self.audio
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
@@ -3540,6 +3605,7 @@ impl Game<'_> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
self.native_tick();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
@@ -4619,6 +4685,7 @@ impl Game<'_> {
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&id));
if !player && self.remote_mob_takes(i, hand) {
self.remote_feed_particles(i, hand);
self.with_hand(hand, Game::swing_arm);
return true;
}
@@ -4626,9 +4693,26 @@ impl Game<'_> {
false
}
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
/// player has infinite materials.
fn remote_use_block(&mut self, hand: Hand) -> bool {
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
let swings = self.swings_started;
let done = self.remote_use_item_on(hand);
if done
&& self.swings_started != swings
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
&& (Some(after) != before || self.creative)
{
self.heights.item_used(hand);
}
done
}
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
/// (the block reacts, or a block item is placed).
fn remote_use_block(&mut self, hand: Hand) -> bool {
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
let held = self.with_hand(hand, |g| g.held().cloned());
let held_block = held
.as_ref()
@@ -4800,6 +4884,9 @@ impl Game<'_> {
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
}
if self.jukebox_takes(hit.pos, held) {
return (true, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
// no UseItem follows (the server does the harvest, see `use_beehive`).
@@ -5002,7 +5089,9 @@ impl Game<'_> {
/// the main hand's item, then the off hand's turn (its block, its item).
/// The server does each packet's part with that packet's hand only.
fn remote_use_hands(&mut self) {
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
if self.remote_use_item(Hand::Main) {
self.heights.item_used(Hand::Main);
return;
}
// The off hand's turn: the entity again, or the block.
@@ -5014,7 +5103,9 @@ impl Game<'_> {
if off_target {
return;
}
self.remote_use_item(Hand::Off);
if self.remote_use_item(Hand::Off) {
self.heights.item_used(Hand::Off);
}
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a consumable's eating animation and
@@ -5913,26 +6004,42 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
let shown = horizon_client::net::shown_address(&r.session.address);
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
let tw = HudBatch::text_width(atlas, line);
batch.text(
atlas,
line,
(w - tw) / 2.0,
h / 2.0 - 12.0 + i as f32 * 12.0,
if i == 0 {
u32::MAX
} else {
rgba(160, 160, 160, 255)
},
true,
);
if r.joined {
// LevelLoadingScreen: the world's terrain comes in.
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
horizon_client::level_loading::draw(batch, atlas, None, None);
return true;
}
let _ = r.joined;
let cancel = self.connect_cancel((w, h)).is_some();
let status = horizon_client::text::Text::literal(r.status.as_str());
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
true
}
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
self.remote.is_some() && self.player.is_none()
}
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
/// joined through `ConnectScreen`), until the login is done.
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
let r = self.remote.as_ref()?;
(self.player.is_none() && !r.joined && self.integrated.is_none())
.then(|| horizon_client::level_loading::connect_cancel(w, h))
}
/// A click on `ConnectScreen`: Cancel aborts the connection
/// (`connect.aborted`) and goes back. Escape does nothing there
/// (`shouldCloseOnEsc` is false).
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
return Ok(());
};
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
self.audio.play_button_click();
self.leave_server(None)?;
}
Ok(())
}
/// The server's death message; chat uses the shared ChatComponent.
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
let Some(r) = &self.remote else { return };
@@ -93,4 +93,45 @@ impl Game<'_> {
_ => false,
}
}
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
/// client's copy: a baby fed something that ages it draws one happy
/// villager particle. The server draws it on its own level only, so no
/// packet brings it.
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
return;
};
let stack = match hand {
Hand::Main => self.stack_in(self.selected),
Hand::Off => self.offhand.as_ref(),
};
let item = stack.map_or("", |s| &*s.item);
let ages = match m.species() {
// Llama.handleEating: wheat 10, hay 90.
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
// Camel.handleEating: its food ages it by 10.
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
// AbstractHorse.handleEating: every food but the red mushroom.
Species::Horse | Species::Donkey | Species::Mule => matches!(
item,
"minecraft:wheat"
| "minecraft:sugar"
| "minecraft:hay_block"
| "minecraft:apple"
| "minecraft:carrot"
| "minecraft:golden_carrot"
| "minecraft:golden_apple"
| "minecraft:enchanted_golden_apple"
),
_ => false,
};
if ages {
let id = m.id;
self.particles_around_mob(
id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
}
}
+211 -4
View File
@@ -10,7 +10,7 @@
use super::end::GatewayJob;
use super::nether_portal::{PLAYER_DELAY, Plan};
use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use super::{Dim, Game, Mob, boats, lan::LanHost, minecarts};
use horizon_client::{lan::hub::TickCtx, text::Text, world_spawn::PlayerSpawnFinder};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -20,11 +20,17 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
pub(super) struct Trip {
conn: u32,
dim: Dim,
/// The level the player came from: the vehicle it rides waits there
/// until the exit is found.
source: Dim,
target: Vec3,
/// Through an End portal.
end: bool,
/// The Nether portal plan, whose exit is found or built on arrival.
plan: Option<Plan>,
/// The vehicle the player rides, which travels with it
/// (`Entity.teleportCrossDimension` carries the passengers).
vehicle: Option<u32>,
/// Through an End gateway, within the level: its exit, found or built
/// on arrival.
gateway: Option<GatewayJob>,
@@ -40,6 +46,66 @@ pub(super) struct Trip {
placed: bool,
}
/// A vehicle a player rides: an animal, a boat or a minecart. It travels
/// with the player through a portal (`Entity.teleportCrossDimension` carries
/// the passengers, and they ride the vehicle that is recreated in the other
/// dimension).
trait Rideable {
fn id(&self) -> u32;
/// The vehicle stands where its rider arrives, with no motion.
fn arrive(&mut self, at: Vec3, yaw: f32);
}
impl Rideable for Mob {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
self.yaw = yaw;
self.previous_yaw = yaw;
self.fall_distance = 0.0;
}
}
impl Rideable for boats::Boat {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
self.yaw = yaw;
self.previous_yaw = yaw;
self.delta_rotation = 0.0;
}
}
impl Rideable for minecarts::Minecart {
fn id(&self) -> u32 {
self.id
}
fn arrive(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.velocity = Vec3::ZERO;
self.yaw = yaw;
self.previous_yaw = yaw;
}
}
/// The vehicle `id` leaves the level it is in, at the point its rider
/// arrives. It keeps its entity id, so the ride is never broken.
fn take_vehicle<T: Rideable>(list: &mut Vec<T>, id: u32, at: Vec3, yaw: f32) -> Option<T> {
let i = list.iter().position(|v| v.id() == id)?;
let mut v = list.remove(i);
v.arrive(at, yaw);
Some(v)
}
/// `TeleportCommand` into another level for a server's player: where, and
/// the rotation it sets (none keeps the player's).
pub(super) struct LevelTeleport {
@@ -66,6 +132,14 @@ impl Game<'_> {
self.server_trips.iter().any(|t| t.conn == conn)
}
/// Whether a vehicle waits in `dim` for its rider to arrive elsewhere:
/// that level stays open until it has moved.
pub(super) fn vehicle_waits_in(&self, dim: Dim) -> bool {
self.server_trips
.iter()
.any(|t| t.vehicle.is_some() && t.source == dim)
}
/// Per server tick in each level, after the guests' own: the arrivals of
/// the trips into this level, then `Entity.handlePortal` for each player
/// in it (Nether portals count up their transition time, End portals
@@ -76,6 +150,7 @@ impl Game<'_> {
if self.options.dedicated.is_none() {
return;
}
self.carry_riders_through_portals(host);
let conns: Vec<u32> = host
.hub
.guests
@@ -101,13 +176,16 @@ impl Game<'_> {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (at, invulnerable, sleeping) = (
let (at, invulnerable, sleeping, riding) = (
Vec3::new(g.position[0], g.position[1], g.position[2]),
g.creative(),
g.sleeping,
g.vehicle.is_some(),
);
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
if let Some(pos) = self.nether_portal_inside(at) {
// `canUsePortal(false)` is false for a passenger: its vehicle goes
// through and carries it (`carry_riders_through_portals`).
if !riding && let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
@@ -122,7 +200,7 @@ impl Game<'_> {
return;
}
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
if end_portal && !riding {
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
@@ -184,6 +262,44 @@ impl Game<'_> {
self.start_server_trip(host, conn, plan.dim, Some(plan));
}
/// `Entity.handlePortal` of a vehicle server players ride, once through
/// a portal (a vehicle's transition time is 0): the riders travel with
/// it and ride it again on arrival (`Entity.teleportCrossDimension`), so
/// a rider never waits for its own portal delay.
fn carry_riders_through_portals(&mut self, host: &mut LanHost) {
for (vehicle, entry) in self.take_carrying(self.dim) {
let Some(plan) = self.vehicle_plan(vehicle, entry) else {
continue;
};
if !self.may_enter(plan.dim) {
continue;
}
let riders: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.vehicle == Some(vehicle) && !self.on_server_trip(g.conn))
.map(|g| g.conn)
.collect();
// Entity.canTeleport: out of the End, every rider has seen the
// credits.
if plan.end
&& self.dim == Dim::End
&& riders
.iter()
.any(|c| !host.hub.guest(*c).is_some_and(|g| g.seen_credits))
{
continue;
}
// An End portal's exit is found as a player's (its own respawn
// point out of the End).
let (target, plan) = (plan.dim, (!plan.end).then_some(plan));
for conn in riders {
self.start_server_trip(host, conn, target, plan);
}
}
}
/// A Nether portal block a player's box is in.
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
let w = WIDTH / 2.0 - 1.0e-5;
@@ -217,6 +333,15 @@ impl Game<'_> {
let g = &host.hub.guests[i];
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
let yaw = g.yaw;
// The vehicle the player rides travels with it: the player is its
// passenger, and the vehicle is recreated in the other dimension,
// where they ride it again (`Entity.teleportCrossDimension`). It
// keeps its entity id, so the ride is never broken.
let vehicle = g.vehicle.filter(|id| {
self.mobs.iter().any(|m| m.id == *id)
|| self.boats.iter().any(|b| b.id == *id)
|| self.carts.iter().any(|c| c.id == *id)
});
let source = self.dim;
let end = plan.is_none();
// Coordinates scale by 8 between the Overworld and the Nether; an
@@ -254,9 +379,11 @@ impl Game<'_> {
self.server_trips.push(Trip {
conn,
dim: target,
source,
target: destination,
end,
plan,
vehicle,
gateway: None,
respawn: None,
world_spawn: false,
@@ -275,9 +402,11 @@ impl Game<'_> {
self.server_trips.push(Trip {
conn,
dim: config.dim,
source: self.dim,
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
end: false,
plan: None,
vehicle: None,
gateway: None,
respawn: Some(config),
world_spawn: false,
@@ -325,9 +454,11 @@ impl Game<'_> {
self.server_trips.push(Trip {
conn,
dim: Dim::Overworld,
source: self.dim,
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
end: false,
plan: None,
vehicle: None,
gateway: None,
respawn: None,
world_spawn: true,
@@ -371,9 +502,11 @@ impl Game<'_> {
self.server_trips.push(Trip {
conn,
dim: self.dim,
source: self.dim,
target: destination,
end: false,
plan: None,
vehicle: None,
gateway: Some(job),
respawn: None,
world_spawn: false,
@@ -474,6 +607,12 @@ impl Game<'_> {
_ => (trip.target, yaw),
}
};
// The vehicle the player rode comes with it, and waits for it
// where it arrives (`Entity.teleportCrossDimension` carries the
// passengers).
if let Some(vehicle) = trip.vehicle {
self.arrive_vehicle(trip.source, vehicle, position, yaw);
}
// Player.getDimensionChangingDelay.
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
let g = &mut host.hub.guests[i];
@@ -485,6 +624,31 @@ impl Game<'_> {
}
}
/// The vehicle the player rode arrives with it, at the point the player
/// arrives: it keeps its entity id, so the ride is never broken
/// (`Entity.teleportCrossDimension` recreates the vehicle in the other
/// dimension, and its passengers ride it again).
fn arrive_vehicle(&mut self, source: Dim, id: u32, at: Vec3, yaw: f32) {
let Some(parked) = self.levels.parked_mut(source) else {
return;
};
if let Some(b) = take_vehicle(&mut parked.boats, id, at, yaw) {
self.boats.push(b);
} else if let Some(c) = take_vehicle(&mut parked.carts, id, at, yaw) {
self.carts.push(c);
} else if let Some(m) = take_vehicle(&mut parked.mobs, id, at, yaw) {
self.mobs.push(m);
} else {
return;
}
// Its memory (its UUID and the compound it was loaded with) moves
// with it, so it is saved in this level as it was in the other.
self.move_entity_memory(source, id);
// The vehicle that came through a portal does not go back through it
// for a while (`Entity.setPortalCooldown`).
self.set_portal_cooldown(id);
}
/// The players the command just run sends to another level
/// (`ServerPlayer.teleport` from TeleportCommand): a Respawn packet
/// keeping all data and the teleport, with no portal sound. The server
@@ -511,9 +675,13 @@ impl Game<'_> {
self.server_trips.push(Trip {
conn: t.conn,
dim: t.dim,
source,
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
end: false,
plan: None,
// A command teleports the player alone: it is not a
// passenger any more (`ServerPlayer.teleport`).
vehicle: None,
gateway: None,
respawn: None,
world_spawn: false,
@@ -547,3 +715,42 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// The vehicle a player rides travels with it through a portal: it
/// leaves the level the player came from, keeps its entity id (so the
/// ride is never broken) and stands where its rider arrives
/// (`Entity.teleportCrossDimension` carries the passengers, and they
/// ride the vehicle that is recreated in the other dimension).
#[test]
fn the_vehicle_a_player_rides_travels_with_it() {
let mut boats = vec![boats::Boat::new(
7,
Arc::from("oak"),
Vec3::new(0.0, 64.0, 0.0),
0.0,
)];
let mut carts: Vec<minecarts::Minecart> = Vec::new();
let mut mobs: Vec<Mob> = Vec::new();
let at = Vec3::new(1.5, 65.0, -2.5);
let boat = take_vehicle(&mut boats, 7, at, 90.0)
.expect("the vehicle the player rides travels with it");
assert!(
boats.is_empty(),
"the vehicle left the level the player came from"
);
assert_eq!(boat.id, 7, "the vehicle keeps its entity id");
assert_eq!(boat.position, at, "it stands where its rider arrives");
assert_eq!(boat.yaw, 90.0, "it looks where its rider looks");
assert_eq!(boat.velocity, Vec3::ZERO, "it comes with no motion");
// A vehicle that is not in the level does not travel.
assert!(take_vehicle(&mut carts, 9, at, 0.0).is_none());
assert!(carts.is_empty());
assert!(take_vehicle(&mut mobs, 9, at, 0.0).is_none());
assert!(mobs.is_empty());
}
}
@@ -167,6 +167,7 @@ impl StartupMenu {
}
match event.physical_key {
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
PhysicalKey::Code(KeyCode::Tab) => title.tab(),
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => title.confirm_edit(),
PhysicalKey::Code(
@@ -392,7 +393,7 @@ impl Loading {
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) => {}
Event::ModSettings(_) | Event::NativeButton(..) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+1 -1
View File
@@ -1643,7 +1643,7 @@ impl Game<'_> {
continue;
};
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
+1 -1
View File
@@ -785,7 +785,7 @@ impl Game<'_> {
for i in slots {
let (sx, sy) = self.slot_position(i);
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
@@ -283,6 +283,27 @@ pub(super) fn world_data_used() {
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
}
/// A server's world opens: the loot tables its first placed block or broken
/// container needs (`loot_data`) are read on a helper thread while the
/// connection is made, so the game thread never waits on them (~0.6 s on a
/// phone). Kept from then on, as a local world keeps them.
pub(super) fn preload_server_loot(resources: &std::path::Path) {
world_data_used();
if horizon_client::structure_blocks::loaded_loot_data().is_some() {
return;
}
let resources = resources.to_owned();
let spawned = std::thread::Builder::new()
.name("Loot preload".into())
.spawn(move || {
horizon_threads::behind_server();
let _ = horizon_client::structure_blocks::loot_data(&resources);
});
if let Err(e) = spawned {
log::warn!("Horizon: loot preload: {e}");
}
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
+42 -49
View File
@@ -25,12 +25,26 @@ pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
match layer {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
}
}
#[derive(Default)]
pub(super) struct EntityBatch {
// Quad indices avoid copying vertices twice while grouping materials.
// Keep initialized high-water storage; local counts delimit active entries.
// Each bucket can hold all quads, removing per-quad Vec growth checks.
buckets: [Vec<usize>; MATERIALS],
// Runs of consecutive quads of one material (first quad, quad count): a
// model's quads share one, so whole runs are copied at once. Cleared each
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
}
@@ -43,50 +57,29 @@ impl EntityBatch {
see_through: &[Vertex],
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for bucket in &mut self.buckets {
if bucket.len() < quads.len() {
bucket.resize(quads.len(), 0);
}
for runs in &mut self.runs {
runs.clear();
}
let mut counts = [0usize; MATERIALS];
for (index, quad) in quads.iter().enumerate() {
let material = match quad[0].layer {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
};
self.buckets[material][counts[material]] = index;
counts[material] += 1;
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
start = end;
}
let entity_vertices = quads.len() * 4;
self.combined.reserve(
(entity_vertices + additive.len() + hand.len() + see_through.len())
.saturating_sub(self.combined.len()),
);
self.combined.clear();
self.combined
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
if counts.contains(&quads.len()) {
// Uniform materials are already ordered; copy the whole batch.
self.combined.copy_from_slice(&entities[..entity_vertices]);
} else {
let mut offset = 0;
for (bucket, &count) in self.buckets.iter().zip(&counts) {
let bucket = &bucket[..count];
let end = offset + count * 4;
for (output, &index) in self.combined[offset..end]
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(bucket)
{
*output = quads[index];
}
offset = end;
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
for runs in &self.runs {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
self.combined.extend_from_slice(additive);
@@ -174,12 +167,12 @@ mod tests {
let hand = geometry(2, false);
// Warm every bucket and the combined buffer to their maximum sizes.
equivalent(&mut batch, &entities, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
let pointer = batch.combined.as_ptr();
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
assert_eq!(batch.combined.as_ptr(), pointer);
}
@@ -188,11 +181,11 @@ mod tests {
// Grow again, then confirm empty/small frames retain the new storage.
let larger = geometry(8192, true);
equivalent(&mut batch, &larger, &additive, &hand);
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
let capacities = batch.runs.each_ref().map(|b| b.capacity());
let capacity = batch.combined.capacity();
for entities in [&larger[..7], &larger[..], &[]] {
equivalent(&mut batch, entities, &[], &[]);
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
assert_eq!(batch.combined.capacity(), capacity);
}
}
@@ -255,9 +248,9 @@ mod tests {
before[7] as f64 / after[7] as f64,
batch.combined.capacity() * std::mem::size_of::<Vertex>()
+ batch
.buckets
.runs
.iter()
.map(|b| b.capacity() * std::mem::size_of::<usize>())
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
.sum::<usize>()
);
}
+9 -5
View File
@@ -801,6 +801,8 @@ impl HealthHud {
/// nausea blits (their blending needs the screen-effect pipeline).
pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, draws: &[GuiDraw]) {
let mut stack = vec![IDENTITY];
// One buffer for the sprite keys, instead of a string per drawn sprite.
let mut name = String::new();
for d in draws {
let start = batch.vertices.len();
let pose = *stack.last().unwrap_or(&IDENTITY);
@@ -817,12 +819,14 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
} => {
// Effect sprites belong to the mob_effect atlas directory;
// the other blitSprite ids refer to GUI sprites.
let name = if sprite.starts_with("mob_effect/") {
sprite.to_string()
name.clear();
if sprite.starts_with("mob_effect/") {
name.push_str(sprite);
} else {
format!("gui/sprites/{sprite}")
};
let Some(mut r) = atlas.sprites.get(&name).copied() else {
name.push_str("gui/sprites/");
name.push_str(sprite);
}
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
continue;
};
if let Some([sw, sh, u, v]) = *region {
+11 -4
View File
@@ -1,5 +1,7 @@
//! `EffectsInInventory`: active effect panels beside survival and creative
//! inventories, with compact icons when there is little room on the right.
use std::borrow::Cow;
use crate::{
effects::EffectInstance,
hud::{HudAtlas, HudBatch},
@@ -123,17 +125,19 @@ pub fn paint(
cursor: [i32; 2],
) -> Option<[(String, u32); 2]> {
let mut tooltip = None;
let mut icon = String::new();
for panel in panels {
background(batch, atlas, panel);
let text_width = panel.width - 39;
let clipped = atlas.font.width(&panel.name).ceil() as i32 > text_width;
if text_width > 0 {
let shown = if clipped {
let shown: Cow<'_, str> = if clipped {
let available = text_width as f32 - atlas.font.width("...");
let count = atlas.font.fit(&panel.name, available);
format!("{}...", panel.name.chars().take(count).collect::<String>())
let text: String = panel.name.chars().take(count).collect();
Cow::Owned(format!("{text}..."))
} else {
panel.name.clone()
Cow::Borrowed(panel.name.as_str())
};
batch.text(
atlas,
@@ -152,9 +156,12 @@ pub fn paint(
true,
);
}
icon.clear();
icon.push_str("mob_effect/");
icon.push_str(&panel.icon.id);
batch.sprite_scaled(
atlas,
&format!("mob_effect/{}", panel.icon.id),
&icon,
(panel.x + 7) as f32,
(panel.y + 7) as f32,
18.0,
+27
View File
@@ -28,6 +28,14 @@ pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack
out
}
/// `DyeColor.byId`: an unknown id is white (`OutOfBoundsStrategy.ZERO`).
fn dye_name(id: i32) -> &'static str {
usize::try_from(id)
.ok()
.and_then(|i| crate::block_entity_render::DYES.get(i))
.map_or("white", |d| d.0)
}
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
Arc::from(
builtin
@@ -61,6 +69,25 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
("pattern", Tag::String(pattern.into())),
])
}
// BannerPatternLayers: each layer's pattern is a synced registry entry.
Component::BannerPatterns(layers) => {
let layers: Option<Vec<Tag>> = layers
.iter()
.map(|(pattern, color)| {
let pattern = synced.name("minecraft:banner_pattern", *pattern)?;
Some(Tag::compound([
("pattern", Tag::String(pattern.into())),
("color", Tag::String(dye_name(*color).into())),
]))
})
.collect();
let Some(layers) = layers else {
continue;
};
Tag::List(layers)
}
// A shield's `base_color`: the dye's id.
Component::Int(v) if *kind == component::BASE_COLOR => Tag::String(dye_name(*v).into()),
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
+39
View File
@@ -824,6 +824,7 @@ impl ChunkEncoder {
tag: block_entities
.get(&pos)
.and_then(entity_tag)
.or_else(|| pot_tag(chunk, pos))
.unwrap_or(Tag::End),
});
}
@@ -938,6 +939,28 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
match entity {
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
BlockEntity::Spawner(s) => Some(s.update_tag()),
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
// the custom name and the components.
BlockEntity::Banner(b) => {
let mut entries = Vec::new();
if !b.layers.is_empty() {
entries.push(("patterns", crate::banner::layers_to_nbt(&b.layers)));
}
if let Some(name) = &b.name {
entries.push(("CustomName", Tag::String(name.as_str().into())));
}
entries.push(("components", Tag::Compound(b.components.clone())));
Some(Tag::compound(entries))
}
// VaultBlockEntity.getUpdateTag: the shared data, which holds the display item.
BlockEntity::Vault(v) => Some(v.update_tag()),
// TrialSpawnerStateData.getUpdateTag: the spawn data the mob is drawn from.
// next_mob_spawns_at (ACTIVE only) is left out: nothing in the client
// reads it, and the block state it depends on is not known here.
BlockEntity::TrialSpawner(t) => t
.next_spawn_data
.as_ref()
.map(|d| Tag::compound([("spawn_data", d.encode())])),
// SkullBlockEntity: the profile is the whole reason a head differs.
BlockEntity::Skull(s) => {
let mut entries = Vec::new();
@@ -962,6 +985,22 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
}
}
/// The update tag of the decorated pot at `pos` (DecoratedPotBlockEntity.getUpdateTag,
/// saveCustomOnly). A pot is not a block entity here: its tag is the chunk's saved one.
pub fn pot_tag(chunk: &Chunk, pos: (i32, i32, i32)) -> Option<Tag> {
let at = chunk.saved.as_ref()?.block_entities.iter().find(|t| {
t.get("id") == Some(&Tag::String("minecraft:decorated_pot".into()))
&& t.get("x") == Some(&Tag::Int(pos.0))
&& t.get("y") == Some(&Tag::Int(pos.1))
&& t.get("z") == Some(&Tag::Int(pos.2))
})?;
let entries: Vec<_> = ["sherds", "LootTable", "LootTableSeed", "item"]
.into_iter()
.filter_map(|k| Some((k, at.get(k)?.clone())))
.collect();
(!entries.is_empty()).then(|| Tag::compound(entries))
}
/// Light sections run from one below the world to one above it. A layer
/// exists where vanilla's light storage keeps one: within one section of a
/// non-air section of this column or a neighbouring one (`around`, bit i for
+1 -1
View File
@@ -381,7 +381,7 @@ fn serve_legacy(mut stream: TcpStream, shared: &Shared) -> Result<(), String> {
let body = if data.len() == 1 {
format!("{motd}\u{a7}{online}\u{a7}{max}")
} else {
if data[1] != 1 {
if data.get(1) != Some(&1) {
return Ok(());
}
if data.len() > 2 {
+93
View File
@@ -459,6 +459,22 @@ impl Guest {
let range = if self.creative() { 5.0 } else { 4.5 };
within_block_range(eye, range + buffer, pos)
}
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
/// guest's body in its pose (crouching, swimming, gliding).
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
let eye = self
.motion
.as_ref()
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
let range = if self.creative() { 5.0 } else { 4.5 };
within_block_range(
[self.position[0], self.position[1] + eye, self.position[2]],
range + 1.0,
pos,
)
}
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
/// 45 off hand.
@@ -523,6 +539,14 @@ pub fn firework_components(
out
}
/// `DyeColor.getId` of a dye's name.
fn dye_id(name: &str) -> Option<i32> {
crate::block_entity_render::DYES
.iter()
.position(|d| d.0 == name)
.map(|i| i as i32)
}
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
/// `synced_id` names an entry of a synced registry (enchantments, trim
/// materials and patterns) by its id on this connection.
@@ -595,6 +619,36 @@ fn network_stack(
));
}
}
// BannerPatternLayers: each pattern a reference into the synced registry;
// a layer that has none leaves the stack without the component.
let layers: Option<Vec<(i32, i32)>> = d
.banner_patterns
.iter()
.map(|layer| {
let name = if layer.pattern.contains(':') {
layer.pattern.to_string()
} else {
format!("minecraft:{}", layer.pattern)
};
Some((
synced_id("minecraft:banner_pattern", &name)?,
dye_id(&layer.color)?,
))
})
.collect();
if let Some(layers) = layers.filter(|layers| !layers.is_empty()) {
stack.added.push((
horizon_protocol::item::component::BANNER_PATTERNS,
horizon_protocol::item::Component::BannerPatterns(layers),
));
}
// A shield's `base_color`.
if let Some(id) = d.base_color.as_deref().and_then(dye_id) {
stack.added.push((
horizon_protocol::item::component::BASE_COLOR,
horizon_protocol::item::Component::Int(id),
));
}
if let Some(id) = d.map_id {
stack.added.push((
horizon_protocol::item::component::MAP_ID,
@@ -771,4 +825,43 @@ mod tests {
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
assert_eq!(stack.component(component::TRIM), None);
}
fn guest_at(position: [f64; 3]) -> Guest {
let (tx, _rx) = std::sync::mpsc::channel();
Guest::new(
Joined {
conn: 0,
uuid: 0,
name: "guest".into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: String::new(),
sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:1".parse().unwrap(),
},
1,
position,
10,
)
}
#[test]
fn a_block_action_must_be_within_reach() {
let mut g = guest_at([0.5, 64.0, 0.5]);
// Survival (4.5 + 1 from the eyes): five blocks off horizontally,
// not one further.
assert!(g.may_reach_block([5, 64, 0]));
assert!(!g.may_reach_block([6, 64, 0]));
// Creative's 5.0 reaches one block further.
g.game_mode = CREATIVE;
assert!(g.may_reach_block([6, 64, 0]));
assert!(!g.may_reach_block([7, 64, 0]));
// With no body yet the sneak flag gives the eye height: a block at
// the edge above is gone once crouched.
g.game_mode = SURVIVAL;
assert!(g.may_reach_block([0, 71, 0]));
g.sneaking = true;
assert!(!g.may_reach_block([0, 71, 0]));
}
}
+16 -8
View File
@@ -1566,10 +1566,15 @@ impl Hub {
}
// Block entities with data the client shows.
for &(x, y, z) in &positions {
let Some(entity) = world.block_entities.get(&(x, y, z)) else {
continue;
// A decorated pot is no block entity: its tag is the chunk's saved one.
let tag = match world.block_entities.get(&(x, y, z)) {
Some(entity) => super::chunks::entity_tag(entity),
None => world
.chunks
.get(&(x >> 4, z >> 4))
.and_then(|c| super::chunks::pot_tag(c, (x, y, z))),
};
let Some(tag) = super::chunks::entity_tag(entity) else {
let Some(tag) = tag else {
continue;
};
let state = world.block(x, y, z).0;
@@ -2216,13 +2221,16 @@ impl Hub {
// game's, run after this pass. A move queued behind it is checked
// against the block the player just broke and falls through, so it
// would be sent back; it waits for the next tick, when the block is
// gone, as vanilla handles the two packets in order.
// gone, as vanilla handles the packets in order. So does everything
// queued before that move: the arm swing (Punch) comes between the dig
// and the move.
S::PlayerAction(p) if p.action <= 3 => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
if matches!(
self.guests[i].inbox.front(),
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
) {
if self.guests[i]
.inbox
.iter()
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
{
break;
}
}
@@ -136,6 +136,35 @@ pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progres
batch.fill(atlas, left, top, done, 2.0, opaque(0x00FF00));
}
/// `ConnectScreen`'s Cancel button corner on a GUI `w` x `h`:
/// (width / 2 - 100, height / 4 + 120 + 12), 200 x 20.
pub fn connect_cancel(w: f32, h: f32) -> (f32, f32) {
((w / 2.0).trunc() - 100.0, (h / 4.0).trunc() + 132.0)
}
/// `ConnectScreen.extractRenderState` over the menu background: the status
/// centred at (width / 2, height / 2 - 50) and, while the connection can
/// still be cancelled, Cancel.
pub fn draw_connecting(
batch: &mut HudBatch,
atlas: &HudAtlas,
status: &Text,
cursor: (f32, f32),
cancel: bool,
) {
let (w, h) = (batch.width, batch.height);
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let tw = atlas.font.width_text(status);
let (x, y) = (((w - tw) / 2.0).trunc(), (h / 2.0).trunc() - 50.0);
batch.text_styled(atlas, status, x, y, u32::MAX, true);
if cancel {
let (x, y) = connect_cancel(w, h);
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
let label = Text::translate("gui.cancel", &[]);
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, true, hover);
}
}
#[cfg(test)]
mod tests {
use super::*;
+10 -1
View File
@@ -538,6 +538,8 @@ pub enum DamageKind {
Freeze,
/// `fell_out_of_world`: below the world's floor.
OutOfWorld,
/// `cramming`: too many entities in one place (`maxEntityCramming`).
Cramming,
}
/// A damage source: its type, the attacking entity and where it came from.
#[derive(Clone, Copy, Debug)]
@@ -616,6 +618,7 @@ impl Damage {
| DamageKind::SonicBoom
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::Cramming
)
}
pub fn is_fire(&self) -> bool {
@@ -642,6 +645,7 @@ impl Damage {
| DamageKind::InWall
| DamageKind::Freeze
| DamageKind::OutOfWorld
| DamageKind::Cramming
)
}
pub fn is_projectile(&self) -> bool {
@@ -1119,10 +1123,12 @@ pub enum MobEvent {
/// The mob the `Explode` right after this comes from
/// (`ServerExplosion.source`), and whether it is a charged creeper: the
/// exploding creeper is already out of the mob list when the game applies
/// the blast, so its victims' loot cannot look it up.
/// the blast, so its victims' loot cannot look it up, nor the death
/// message its name (`name`, `getDisplayName`).
ExplosionSource {
mob: u32,
charged: bool,
name: crate::text::Text,
},
/// The centre, the radius and the kind of the mob blowing up (it may be
/// gone from the list by the time the blast is handled).
@@ -1822,6 +1828,8 @@ pub struct MobWorld<'a> {
pub mob_griefing: bool,
/// The `mob_drops` game rule (`shouldDropLoot`).
pub mob_drops: bool,
/// The `max_entity_cramming` game rule.
pub max_entity_cramming: i32,
/// `getCurrentDifficultyAt` at the mob being ticked (near the player
/// outside the mob tick).
pub difficulty: spawning::LocalDifficulty,
@@ -2215,6 +2223,7 @@ impl Mob {
| DamageKind::InWall
| DamageKind::Thorns
| DamageKind::OutOfWorld
| DamageKind::Cramming
| DamageKind::Generic
)
}
+16 -8
View File
@@ -1084,7 +1084,8 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& m.species() != Species::Shulker
&& !on_climbable(m, w)
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
let mut targets: Vec<(Who, Vec3)> = Vec::new();
// Each with whether it rides a vehicle (`Entity.isPassenger`).
let mut targets: Vec<(Who, Vec3, bool)> = Vec::new();
if let Some(p) = &w.player
// Entity.push: passengers of the same vehicle never push each other.
&& m.rider.is_none()
@@ -1092,21 +1093,27 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& !p.spectator
&& p.bounds.intersects(bounds)
{
targets.push((Who::Player, p.position));
targets.push((Who::Player, p.position, p.passenger));
}
for o in w.others_near(bounds) {
if o.id != m.id && o.alive && o.pushable && o.bounds.intersects(bounds) {
targets.push((Who::Mob(o.id), o.position));
targets.push((Who::Mob(o.id), o.position, o.passenger));
}
}
// EntitySelector.pushableBy(this): the teams' collision rules.
let own = w.teams.of(Who::Mob(m.id));
targets.retain(|(who, _)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
// Cramming (maxEntityCramming 24).
if targets.len() > 23 {
let _ = m.random.next_int_bound(4);
targets.retain(|(who, ..)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
// Cramming: a quarter of the ticks spent with `maxEntityCramming` or more
// others around that are not riding anything hurts it by 6.
let limit = w.max_entity_cramming;
if limit > 0
&& targets.len() as i64 > i64::from(limit) - 1
&& m.random.next_int_bound(4).unwrap_or(1) == 0
&& targets.iter().filter(|t| !t.2).count() as i64 > i64::from(limit) - 1
{
m.damage(6.0, Damage::of(DamageKind::Cramming));
}
for (who, at) in targets {
for (who, at, _) in targets {
// Warden.doPush: touching it angers it, then the push itself.
if m.species() == Species::Warden {
super::warden::do_push(m, w, who, events);
@@ -1955,6 +1962,7 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
Family::Guardian => super::guardian::travel(m, w, input, events),
// Shulkers never move on their own.
Family::Shulker => true,
Family::Allay => super::allay::travel(m, w, input, events),
Family::Vex => super::vex::travel(m),
Family::Nautilus => super::nautilus::travel(m, w, input, events),
_ => false,
+10 -1
View File
@@ -2,12 +2,13 @@
//! blocks and bring them to their player (or a liked note block), dance to
//! jukeboxes and duplicate when given an amethyst shard mid-dance.
use super::{
Mob,
Mob, MobEvent, MobWorld,
anim::{Poses, State},
control::MoveKind,
goals::{Cx, Goal, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone},
nav::NavKind,
};
use horizon_core::geometry::Vec3;
use horizon_core::geometry::trig::Mth;
// Allay state: `main_hand` the held item, `ints[1]` how many it carries,
@@ -133,6 +134,14 @@ impl Goal for StayClose {
}
}
}
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
/// the one the move control set this tick, and no gravity applies, so an
/// allay never falls under its own travel.
pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>) -> bool {
let speed = m.ai.speed;
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
true
}
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
if m.alive() && m.age.is_multiple_of(10) {
@@ -821,6 +821,22 @@ fn age_boundary_reached(m: &mut Mob) {
_ => {}
}
}
/// `SpawnEggItem.spawnsEntity`: the spawn egg is for the mob's own kind, so
/// it is used on it (`Mob.checkAndHandleImportantInteractions`).
pub fn spawn_egg_is_for(egg: &str, kind: &str) -> bool {
egg.strip_suffix("_spawn_egg") == Some(kind)
}
/// `SpawnEggItem.spawnOffspringFromSpawnEgg`: the offspring of a spawn egg
/// used on a mob of the same kind is a baby of that kind: it starts with
/// the age of a baby and keeps the parent's variant and colour.
pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color: u8) {
set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
}
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
pub fn set_baby(m: &mut Mob, baby: bool) {
let s = m.species();
@@ -2138,6 +2154,39 @@ mod tests {
assert_eq!(panda_variant(4, 4), 4);
assert_eq!(panda_variant(6, 0), 6);
}
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
/// own kind takes the egg.
#[test]
fn a_spawn_egg_offspring_is_a_baby() {
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 kind = std::sync::Arc::new(data.types.get("minecraft:cow").unwrap().clone());
let mut child = crate::mobs::Mob::new(
2,
kind,
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
0.0,
);
assert!(!child.is_baby());
spawn_egg_offspring(&mut child, Species::Cow, 3, 0);
// The offspring is a baby of the mob's kind.
assert!(child.is_baby());
// It keeps the parent's variant.
assert_eq!(child.extra.variant, 3);
// The egg is for the mob's own kind.
assert!(spawn_egg_is_for("minecraft:cow_spawn_egg", "minecraft:cow"));
assert!(!spawn_egg_is_for(
"minecraft:pig_spawn_egg",
"minecraft:cow"
));
assert!(!spawn_egg_is_for("minecraft:stick", "minecraft:cow"));
}
#[test]
fn reasons_round_trip() {
for r in Reason::ALL {
+22 -1
View File
@@ -773,6 +773,8 @@ pub struct Stroll {
pub water_avoiding: Option<f32>,
/// RandomSwimmingGoal: BehaviorUtils.getRandomSwimmablePos.
pub swim: bool,
/// WaterAvoidingRandomFlyingGoal: a hover spot, else an air or water pos.
pub flying: bool,
pub force: bool,
wanted: [f64; 3],
}
@@ -784,6 +786,7 @@ impl Stroll {
check_no_action: true,
water_avoiding: None,
swim: false,
flying: false,
force: false,
wanted: [0.0; 3],
}
@@ -800,7 +803,10 @@ impl Stroll {
}
/// WaterAvoidingRandomFlyingGoal (a nearby air position).
pub fn flying(speed: f64) -> Self {
Self::new(speed)
Self {
flying: true,
..Self::new(speed)
}
}
/// RandomSwimmingGoal(speed, interval)
pub fn swimming(speed: f64, interval: i32) -> Self {
@@ -861,6 +867,21 @@ impl Goal for Stroll {
}
p
}
None if self.flying => {
// WaterAvoidingRandomFlyingGoal.getPosition.
let [xd, _, zd] = m.view_vector();
m.hover_random_pos(
cx.w,
8,
7,
xd,
zd,
f64::from(std::f32::consts::FRAC_PI_2),
3,
1,
)
.or_else(|| m.air_and_water_random_pos(cx.w, 8, 4, -2, xd, zd))
}
None => m.default_random_pos(cx.w, 10, 7),
};
let Some(p) = pos else { return false };
@@ -326,9 +326,17 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.swell >= 30 {
m.swell = 30;
let radius = if m.extra.charged { 6.0 } else { 3.0 };
let name = match &m.custom_name {
Some(name) => crate::text::Text::literal(name.clone()),
None => crate::text::Text::translate(
&format!("entity.{}", m.kind.id.replace(':', ".")),
&[],
),
};
events.push(MobEvent::ExplosionSource {
mob: m.id,
charged: m.extra.charged,
name,
});
events.push(MobEvent::Explode(
[m.position.x, m.position.y, m.position.z],
+24 -3
View File
@@ -935,12 +935,33 @@ impl Mob {
} else {
0.02
};
self.travel_flying_speeds(
w,
input,
events,
fluid_speed,
fluid_speed,
self.kind.air_speed,
);
}
/// `LivingEntity.travelFlying(input, waterSpeed, lavaSpeed, airSpeed)`:
/// the speeds vary per mob (an allay flies at the speed its move control
/// set, `Allay.travel`).
pub fn travel_flying_speeds(
&mut self,
w: &MobWorld,
input: Vec3,
events: &mut Vec<MobEvent>,
water_speed: f32,
lava_speed: f32,
air_speed: f32,
) {
let (speed, drag) = if self.fluids.in_water {
(fluid_speed, f64::from(0.8_f32))
(water_speed, f64::from(0.8_f32))
} else if self.fluids.in_lava {
(fluid_speed, 0.5)
(lava_speed, 0.5)
} else {
(self.kind.air_speed, f64::from(0.91_f32))
(air_speed, f64::from(0.91_f32))
};
self.move_relative(speed, input);
self.do_move(w, events);
+4
View File
@@ -1456,8 +1456,12 @@ fn hand_base(
| Species::WitherSkeleton
);
let arm = if left { "left_arm" } else { "right_arm" };
// `SkeletonModel.translateToHand` shifts the arm by 1, `PlayerModel.translateToHand`
// by 0.5 for slim arms: the hand is the middle of the arm.
let offset = if skeleton {
if left { -1.0 } else { 1.0 }
} else if avatar(m) && m.slim {
if left { -0.5 } else { 0.5 }
} else {
0.0
};
+41
View File
@@ -542,6 +542,47 @@ mod tests {
assert_eq!(m.velocity.y, 0.0);
}
#[test]
fn the_leg_animation_runs_at_vanilla_s_rate() {
// LivingEntity.updateWalkAnimation turns the distance a tick into
// min(distance * 4, 1), and TurtleModel.setupAnim swings the legs
// by 8 * that speed, so the rate of the walk animation is the rate
// of the walk. The turtle walks at its own pace, so the animation
// runs at vanilla's rate instead of at the maximum (#1776).
let mut m = strolling_turtle();
let mut last = m.position;
let mut distance = 0.0_f64;
for _ in 0..40 {
// Entity.tick keeps the position the tick started at.
m.previous = last;
// TurtleMoveControl.tick: the forward input.
move_control(&mut m);
// LivingEntity.travelInAir on grass: the friction influenced
// speed is the input speed (grass' friction is 0.6, so it is
// not above it), the move uses the motion as it is, and the
// motion is then scaled by the friction (0.6) times the air
// drag (0.91). The move itself needs a world, so the flat
// ground is stepped here.
m.move_relative(m.ai.speed, Vec3::new(0.0, 0.0, 1.0));
m.position.x += m.velocity.x;
m.position.z += m.velocity.z;
let drag = f64::from(0.6_f32 * 0.91_f32);
m.velocity.x *= drag;
m.velocity.z *= drag;
// LivingEntity.updateWalkAnimation.
m.update_walk_animation();
distance = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z);
last = m.position;
}
// The ground speed: the input (0.06 + 0.125 * (0.25 - 0.06) =
// 0.08375) over one minus the drag (0.546) = 0.18447 blocks a tick.
assert!((distance - 0.083_75 / (1.0 - 0.546)).abs() < 1.0e-6);
// min(distance * 4, 1) for that pace: 0.73789. The generic
// MoveControl walked the turtle at the full 0.25, which put it at
// 0.55066 blocks a tick and saturated the walk animation at 1.0.
assert!((m.limb_speed - 0.737_885_5).abs() < 1.0e-6);
}
#[test]
fn water_speed_is_cut_away_from_home_and_for_babies() {
// updateSpeed in water near home: no cut, the lerp climbs towards
+69 -16
View File
@@ -1,6 +1,8 @@
//! Which of the installed Horizon mods are on (`docs/mods.md`), kept in
//! `horizon-mods.txt` beside `options.txt` as `mod.<id>:true` lines. Every
//! mod is off until the player turns it on in Options > Mods.
//! mod is off until the player turns it on in Options > Mods. A native mod is
//! built in, so on until turned off (`mod.<id>:false`); the launcher builds
//! only the native mods whose line is `true`.
use std::{
collections::{BTreeMap, BTreeSet},
fs,
@@ -15,6 +17,9 @@ pub const DIR: &str = "horizon-mods";
pub struct ModList {
/// Ids of the mods the player turned on.
pub enabled: BTreeSet<String>,
/// Ids of the native mods the player turned off: they rest
/// (`native_on`) until the launcher's next build leaves them out.
pub disabled: BTreeSet<String>,
/// The keys the player chose for mods' keys, mod id → key name → key
/// (`key.<id>.<name>:key.keyboard.x` lines).
pub keys: BTreeMap<String, BTreeMap<String, String>>,
@@ -36,6 +41,18 @@ pub struct Installed {
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
/// A native mod (`game/native_hooks.rs`): built into the game, on until
/// the player turns it off, and its settings page.
pub native: Option<NativePage>,
}
/// A native mod's settings page: its keys as Key Binds rows, then its
/// buttons. The keys the player chooses are kept in `ModList::keys`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativePage {
/// Each key's name, label and default key (`key.keyboard.z`).
pub keys: Vec<[String; 3]>,
pub buttons: Vec<String>,
}
impl ModList {
@@ -45,13 +62,15 @@ impl ModList {
.unwrap_or_default()
}
pub fn parse(text: &str) -> Self {
let enabled = text
.lines()
.filter_map(|l| l.split_once(':'))
.filter(|(_, v)| v.trim() == "true")
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
.map(str::to_owned)
.collect();
let ids = |value: &str| -> BTreeSet<String> {
text.lines()
.filter_map(|l| l.split_once(':'))
.filter(|(_, v)| v.trim() == value)
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
.map(str::to_owned)
.collect()
};
let (enabled, disabled) = (ids("true"), ids("false"));
let mut keys: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (k, v) in text.lines().filter_map(|l| l.split_once(':')) {
// Mod ids have no dots; a key's name may.
@@ -66,17 +85,19 @@ impl ModList {
}
Self {
enabled,
disabled,
keys,
installed: Vec::new(),
}
}
pub fn serialize(&self) -> String {
let on = self.enabled.iter().map(|id| format!("mod.{id}:true\n"));
let off = self.disabled.iter().map(|id| format!("mod.{id}:false\n"));
let keys = self.keys.iter().flat_map(|(id, keys)| {
keys.iter()
.map(move |(name, key)| format!("key.{id}.{name}:{key}\n"))
});
on.chain(keys).collect()
on.chain(off).chain(keys).collect()
}
/// Mod `id`'s chosen keys, for `Mods::set_bindings`.
pub fn keys_of(&self, id: &str) -> Vec<(&str, &str)> {
@@ -86,22 +107,40 @@ impl ModList {
}
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
if self.enabled.is_empty() && self.keys.is_empty() && !path.exists() {
if self.enabled.is_empty()
&& self.disabled.is_empty()
&& self.keys.is_empty()
&& !path.exists()
{
return;
}
let _ = fs::write(path, self.serialize());
}
pub fn is_on(&self, i: usize) -> bool {
self.installed
.get(i)
.is_some_and(|m| m.dev || self.enabled.contains(&m.id))
self.installed.get(i).is_some_and(|m| {
m.dev
|| if m.native.is_some() {
!self.disabled.contains(&m.id)
} else {
self.enabled.contains(&m.id)
}
})
}
/// Turns installed mod `i` on or off (a dev mod stays on).
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
/// line says `false` when off, so that the launcher's next build leaves
/// it out, and `true` when on again.
pub fn toggle(&mut self, i: usize) {
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
return;
};
if !self.enabled.remove(&m.id) {
if m.native.is_some() {
if self.disabled.remove(&m.id) {
self.enabled.insert(m.id.clone());
} else {
self.enabled.remove(&m.id);
self.disabled.insert(m.id.clone());
}
} else if !self.enabled.remove(&m.id) {
self.enabled.insert(m.id.clone());
}
}
@@ -132,7 +171,7 @@ mod tests {
}
#[test]
fn toggles_installed_mods_but_not_dev_ones() {
fn toggles_installed_and_native_mods_but_not_dev_ones() {
let installed = |id: &str, dev| Installed {
id: id.into(),
title: id.into(),
@@ -140,6 +179,7 @@ mod tests {
problem: None,
dev,
has_settings: false,
native: None,
};
let mut list = ModList {
installed: vec![installed("a", false), installed("b", true)],
@@ -152,5 +192,18 @@ mod tests {
assert_eq!(list.serialize(), "mod.a:true\n");
list.toggle(0);
assert!(!list.is_on(0));
list.installed.push(Installed {
native: Some(NativePage::default()),
..installed("c", false)
});
// On until turned off; off, the launcher leaves it out.
assert!(list.is_on(2));
list.toggle(2);
assert!(!list.is_on(2));
assert_eq!(list.serialize(), "mod.c:false\n");
assert_eq!(ModList::parse(&list.serialize()).disabled, list.disabled);
list.toggle(2);
assert!(list.is_on(2));
assert_eq!(list.serialize(), "mod.c:true\n");
}
}
+4 -12
View File
@@ -734,7 +734,7 @@ impl Screen {
motd.resolve_legacy(),
Ping::player_count(*players),
format!(
"server_list/ping_{}",
"gui/sprites/server_list/ping_{}",
match ms {
0..150 => 5,
150..300 => 4,
@@ -748,7 +748,7 @@ impl Screen {
Text::translate("multiplayer.status.cannot_connect", &[])
.with_color(0xFF0000),
Text::empty(),
"server_list/unreachable".to_owned(),
"gui/sprites/server_list/unreachable".to_owned(),
),
_ => {
// The pinging bars bounce, offset by the row.
@@ -757,7 +757,7 @@ impl Screen {
(
Text::translate("multiplayer.status.pinging", &[]),
Text::empty(),
format!("server_list/pinging_{}", frame + 1),
format!("gui/sprites/server_list/pinging_{}", frame + 1),
)
}
};
@@ -778,15 +778,7 @@ impl Screen {
);
}
let icon_x = cx + ROW_WIDTH - 4.0 - 15.0;
batch.sprite_scaled(
atlas,
&format!("gui/sprites/{sprite}"),
icon_x,
cy,
10.0,
8.0,
u32::MAX,
);
batch.sprite_scaled(atlas, &sprite, icon_x, cy, 10.0, 8.0, u32::MAX);
let sw = atlas.font.width_text(&status).ceil();
batch.text_styled(
atlas,
+28
View File
@@ -467,6 +467,34 @@ mod tests {
assert_eq!(m.current(&engine), Some("minecraft:music.menu"));
}
#[test]
fn a_new_level_stops_menu_music_and_waits_for_game_music() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut m = MusicManager::new(2);
let menu = Music::menu();
m.start_playing(
&menu,
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
// Minecraft.setLevel -> SoundManager.stop(); the next tick has a player.
engine.stop_all(&mut backend);
let game = Music::game();
m.tick(
Some(&game),
1.0,
true,
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
assert!(m.current_instance().is_none());
assert!((12000..=24000).contains(&m.next_song_delay()));
}
#[test]
fn zero_music_volume_fades_out_and_stops() {
let (mut engine, mut backend) = (engine(), Null::default());
+5 -3
View File
@@ -262,13 +262,15 @@ impl Session {
w.shutdown();
}
}
/// `Connection.disconnect`: the socket closes and the game goes on at once.
/// The reader thread is left to end on its own: on Windows a shutdown
/// doesn't wake a read already waiting, so joining it held the window until
/// the server dropped the connection, many seconds later.
pub fn disconnect(&mut self) {
if let Some(w) = self.writer.lock().unwrap().take() {
w.shutdown();
}
if let Some(t) = self.thread.take() {
let _ = t.join();
}
drop(self.thread.take());
}
}
impl Drop for Session {
+79 -3
View File
@@ -820,6 +820,9 @@ pub fn apply_preset(s: &mut Settings) {
}
}
/// `selected_key` while a native mod's key waits (`selected_native_key`).
const NATIVE_KEY: &str = "horizon.nativeModKey";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
Main,
@@ -834,6 +837,8 @@ pub enum Page {
Font,
Packs,
Mods,
/// A native mod's settings page: its index in `mods.installed`.
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// Horizon's graphics settings (`graphics_ui`).
@@ -872,6 +877,9 @@ enum Act {
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
ModSettings(usize),
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
NativeButton(usize, usize),
OpenModFolder,
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
@@ -931,6 +939,9 @@ pub enum Event {
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
/// mod and refreshes the page.
NativeButton(usize, usize),
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
Credits,
/// Copy how an agent connects (the AI Agent page).
@@ -963,8 +974,11 @@ pub struct OptionsUi {
slider_value: Option<(Opt, f32, Instant)>,
/// The pointer's last y while a scrollbar is held.
pub(crate) scrollbar_drag_y: Option<f32>,
/// Key Binds: the mapping waiting for a key.
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
/// mod's, `selected_native_key`).
pub selected_key: Option<&'static str>,
/// The native mod's key waiting: (mod, key) in `mods.installed`.
selected_native_key: Option<(usize, usize)>,
/// Controller Binds: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
/// The glyphs the binds list shows (the setting, else the pad's family).
@@ -1002,6 +1016,7 @@ impl Default for OptionsUi {
slider_value: None,
scrollbar_drag_y: None,
selected_key: None,
selected_native_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
languages: Vec::new(),
@@ -1167,6 +1182,7 @@ impl OptionsUi {
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
@@ -1225,6 +1241,43 @@ impl OptionsUi {
filtered
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let mut rows = vec![Row::Note(m.title.clone())];
let Some(page) = &m.native else {
return rows;
};
let chosen = s.mods.keys.get(&m.id);
for (k, [name, label, default]) in page.keys.iter().enumerate() {
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
let mut message = keybinds::display_name(key);
if self.selected_native_key == Some((i, k)) {
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
rows.push(Row::Buttons(inactive(caption), Some(change)));
}
for (b, label) in page.buttons.iter().enumerate() {
let widget = button(
0.0,
0.0,
310.0,
Text::literal(label.clone()),
Act::NativeButton(i, b),
);
rows.push(Row::Buttons(widget, None));
}
rows
}
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::Extras => {
@@ -1506,7 +1559,7 @@ impl OptionsUi {
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings {
if installed.has_settings || installed.native.is_some() {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
@@ -1526,6 +1579,7 @@ impl OptionsUi {
})
.collect()
}
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
@@ -2556,8 +2610,17 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
events.push(Event::ModSettings(i));
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
} else {
events.push(Event::ModSettings(i));
}
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -2755,6 +2818,18 @@ impl OptionsUi {
let Some(name) = self.selected_key.take() else {
return Vec::new();
};
if name == NATIVE_KEY {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
return Vec::new();
};
let keys = s.mods.keys.entry(m.id.clone()).or_default();
keys.insert(key.clone(), value);
return vec![Event::Changed];
}
if !s.extras.set_key(name, value.clone()) {
s.keys.set(name, &value);
}
@@ -3100,6 +3175,7 @@ mod tests {
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
native: None,
});
}
let (widgets, ..) = ui.layout(&s, size.0, size.1);
+4 -3
View File
@@ -767,9 +767,10 @@ impl Projectile {
}
ProjectileKind::Potion { .. } => "item/splash_potion",
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
ProjectileKind::ShulkerBullet { .. } => "entity/shulker/spark",
// Drawn as `WindChargeModel` by the game.
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => return None,
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
| ProjectileKind::ShulkerBullet { .. } => return None,
ProjectileKind::WitherSkull { dangerous: false } => "entity/wither/wither",
ProjectileKind::WitherSkull { dangerous: true } => "entity/wither/wither_invulnerable",
// Drawn as their models by the game.
+45
View File
@@ -2673,6 +2673,21 @@ pub struct Level {
pub player_extra: Option<Tag>,
}
impl Level {
/// `PrimaryLevelData.setDifficulty`: the level data holds the world's
/// difficulty, so the game and the file follow each other and a world
/// that was set to hard is hard when it is opened again.
pub fn set_difficulty(&mut self, difficulty: u8) {
self.difficulty = crate::title::DIFFICULTIES[difficulty as usize % 4].to_owned();
}
/// `PrimaryLevelData.getDifficulty`: the id of the level data's
/// difficulty (peaceful 0 .. hard 3), normal when it is not one of them.
pub fn difficulty_id(&self) -> u8 {
crate::title::DIFFICULTIES
.iter()
.position(|d| *d == self.difficulty)
.map_or(2, |i| i as u8)
}
/// Initialize the offline single-player identity before saving or dropping items.
pub fn ensure_player_uuid(&mut self) -> [i32; 4] {
*self.player_uuid.get_or_insert_with(|| {
@@ -4916,6 +4931,36 @@ mod tests {
section(Tag::Byte(0), None, Some(Tag::ByteArray(varied))),
]));
}
/// #2571: the difficulty is a property of the world: the level data
/// holds it, so a world that was set to hard is hard when it is opened
/// again, and a world that was downloaded keeps the difficulty it was
/// made with.
#[test]
fn the_level_data_holds_the_difficulty() {
let dir = std::env::temp_dir().join(format!("horizon-difficulty-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut level = Level {
name: "Test".into(),
..Level::default()
};
// The world's difficulty is the level data's.
level.set_difficulty(3);
assert_eq!(level.difficulty, "hard");
assert_eq!(level.difficulty_id(), 3);
// It survives the save, so the world comes back with it.
level.write(&dir).unwrap();
let back = Level::read(&dir).unwrap();
assert_eq!(back.difficulty, "hard");
assert_eq!(back.difficulty_id(), 3);
// A world whose difficulty is not one of ours is normal.
let other = Level {
difficulty: "extreme".into(),
..Level::default()
};
assert_eq!(other.difficulty_id(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn level_round_trips_through_the_vanilla_files() {
let dir = std::env::temp_dir().join(format!("horizon-level-{}", std::process::id()));
+8
View File
@@ -1807,6 +1807,14 @@ impl Title {
_ => self.mode = Mode::Main,
}
}
/// `JoinMultiplayerScreen.keyPressed`: F5 refreshes the server list as
/// Refresh does (`refreshServerList`).
pub fn refresh_servers(&mut self) {
if let Mode::Multiplayer(screen @ crate::multiplayer::Screen::List { .. }) = &mut self.mode
{
*screen = crate::multiplayer::Screen::list(&self.saves);
}
}
}
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
batch.text(
+26
View File
@@ -117,6 +117,32 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
assert!(f.title.focused_field().unwrap().1.is_empty());
}
#[test]
fn f5_reloads_the_server_list() {
let mut f = Fixture::new();
f.click("Multiplayer");
let file = crate::multiplayer::servers_file(&f.title.saves);
horizon_protocol::servers::save(
&file,
&[horizon_protocol::servers::ServerEntry::new(
"Local",
"127.0.0.1:1",
)],
)
.unwrap();
let listed = |f: &Fixture| match &f.title.mode {
Mode::Multiplayer(crate::multiplayer::Screen::List { servers, .. }) => servers.len(),
_ => usize::MAX,
};
assert_eq!(listed(&f), 0);
f.title.refresh_servers();
assert_eq!(listed(&f), 1);
// Only the list refreshes.
f.title.mode = Mode::Main;
f.title.refresh_servers();
assert!(matches!(f.title.mode, Mode::Main));
}
#[test]
fn empty_singleplayer_opens_creation_and_cancel_returns_to_title() {
let mut f = Fixture::new();
+48
View File
@@ -407,6 +407,54 @@ fn sprint_commands_survive_keyboard_input_changes() {
);
}
#[test]
fn a_move_behind_a_dig_and_its_swing_waits_for_the_next_tick() {
let Some(resources) = resources() else {
return;
};
let mut s = server(&resources);
let port = s.hub.lan.port;
let _bot = run(&mut s, move || connect(port, "Digger", true));
let deadline = Instant::now() + Duration::from_secs(5);
while s.hub.guests.is_empty() {
s.tick(&[]);
assert!(Instant::now() < deadline, "guest did not join");
thread::yield_now();
}
s.hub.guests[0].loaded = true;
let at = s.hub.guests[0].position;
let below = [
at[0].floor() as i32,
at[1].floor() as i32 - 1,
at[2].floor() as i32,
];
// A creative break as the client sends it in one tick: the dig, the arm swing,
// then the move into the block it predicted gone.
let inbox = &mut s.hub.guests[0].inbox;
inbox.push_back(S::PlayerAction(play::PlayerAction {
action: 0,
pos: below,
direction: 1,
sequence: 1,
}));
inbox.push_back(S::Punch(Empty));
inbox.push_back(S::MovePlayerPos(play::MovePlayerPos {
x: at[0],
y: at[1] - 0.0784,
z: at[2],
flags: 0,
}));
s.tick(&[]);
// The game breaks the block after the hub's pass: the move waits, or it would be
// checked against the block and sent back.
let g = &s.hub.guests[0];
assert_eq!(g.position, at, "the move was handled before the dig");
assert!(
matches!(g.inbox.back(), Some(S::MovePlayerPos(_))),
"the move left the queue"
);
}
#[test]
fn status_and_join() {
let Some(resources) = resources() else {
+26
View File
@@ -15,6 +15,12 @@ pub struct Manifest {
/// Exact source revisions tested together, never mutable tags.
pub horizon: String,
pub mixins: String,
/// The version of Horizon's native hook points (`game/native_hooks.rs`,
/// `NATIVE_HOOKS_API`) a mod that hooks nothing else needs: the launcher
/// builds it on any Horizon with that version or a later one, and
/// `horizon` is only the revision it was tested on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<u32>,
}
pub fn revision(value: &str) -> bool {
@@ -32,6 +38,7 @@ impl Manifest {
|| self.game.is_empty()
|| !revision(&self.horizon)
|| !revision(&self.mixins)
|| self.hooks == Some(0)
{
return Err(
"invalid native manifest: schema 1, id, version, game and exact revisions required"
@@ -154,6 +161,7 @@ mod tests {
game: "26.3".into(),
horizon: "a".repeat(40),
mixins: "b".repeat(40),
hooks: None,
}
}
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
@@ -235,4 +243,22 @@ mod tests {
.is_err()
);
}
#[test]
fn hooks_is_optional_and_kept() {
let hooked = Manifest {
hooks: Some(1),
..manifest()
};
assert!(hooked.check().is_ok());
let text = serde_json::to_string(&hooked).unwrap();
assert!(text.contains("\"hooks\":1"), "{text}");
assert_eq!(serde_json::from_str::<Manifest>(&text).unwrap(), hooked);
let plain = serde_json::to_string(&manifest()).unwrap();
assert!(!plain.contains("hooks"), "{plain}");
let zero = Manifest {
hooks: Some(0),
..manifest()
};
assert!(zero.check().is_err());
}
}
+22 -4
View File
@@ -211,6 +211,9 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
let mut versions = Vec::new();
for v in &entry.versions {
match version(entry, v, src) {
// A mod that turned native keeps its `.hmod` versions published, but the index offers
// only its native ones: a launcher never installs a `.hmod` as a native mod.
Ok(_) if file_kind(entry, v) != entry.kind => {}
Ok(v) => versions.push(v),
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
}
@@ -235,6 +238,15 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
})
}
/// The kind of a version's file: the project's, but a `.hmod` of a mod that turned native
/// (`review`) is a WebAssembly mod's.
fn file_kind(entry: &Entry, v: &EntryVersion) -> Kind {
match entry.kind {
Kind::NativeMod if v.file.ends_with(".hmod") => Kind::Mod,
_ => entry.kind.clone(),
}
}
fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, String> {
if !plain_version(&v.version) {
return Err("a version is letters, digits, '.', '-' and '_'".into());
@@ -256,7 +268,7 @@ fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, St
(None, Some(_)) => return Err("signed, but the project has no key".into()),
(None, None) => {}
}
let facts = inspect(&entry.kind, &path)?;
let facts = inspect(&file_kind(entry, v), &path)?;
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
return Err(format!(
"the file's id is {:?}, not {:?}",
@@ -343,8 +355,9 @@ pub struct Review {
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
/// a published file never changes and is never removed (take it down in
/// `revoked.toml`), a project's kind never changes, and changes by someone
/// who isn't an owner, or to owners and keys, are pointed out.
/// `revoked.toml`), a project's kind never changes but for a mod turning
/// native (its next versions are `.hnmod`s), and changes by someone who
/// isn't an owner, or to owners and keys, are pointed out.
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
let mut r = Review::default();
for old in base {
@@ -365,7 +378,12 @@ pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
old.owners.join(", ")
));
}
if new.kind != old.kind {
if (&old.kind, &new.kind) == (&Kind::Mod, &Kind::NativeMod) {
r.notes.push(format!(
"{}: turns native; its .hmod versions stay published, and launchers keep the installed copies",
old.id
));
} else if new.kind != old.kind {
r.errors.push(format!("{}: its kind can't change", old.id));
}
for v in &old.versions {
@@ -243,6 +243,33 @@ fn a_review_protects_published_files() {
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
}
#[test]
fn a_mod_may_turn_native_once() {
let base: Entry = toml::from_str(&hud_entry()).unwrap();
let mut native = base.clone();
native.kind = Kind::NativeMod;
let mut next = native.versions[0].clone();
next.version = "2.0.0".into();
next.file = "coords_hud-2.0.0.hnmod".into();
native.versions.insert(0, next);
let r = review(
std::slice::from_ref(&base),
std::slice::from_ref(&native),
"ludovic111",
);
assert!(r.errors.is_empty(), "{r:?}");
assert!(r.notes.iter().any(|n| n.contains("turns native")), "{r:?}");
// Its .hmod versions are a WebAssembly mod's files; the new ones native.
assert_eq!(file_kind(&native, &native.versions[1]), Kind::Mod);
assert_eq!(file_kind(&native, &native.versions[0]), Kind::NativeMod);
// And never back.
let r = review(&[native], &[base], "ludovic111");
assert!(
r.errors.iter().any(|e| e.contains("kind can't change")),
"{r:?}"
);
}
#[test]
fn only_open_kinds_are_taken() {
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
+23 -1
View File
@@ -2,7 +2,7 @@
//! serialized LevelChunkSections with their PalettedContainers, block
//! entities) and ClientboundLightUpdatePacketData (sky/block DataLayers).
use crate::{
Result,
Error, Result,
buf::{BitSet, Bytes, Codec, Longs, Reader, VarInt, Writer},
packet,
};
@@ -83,6 +83,11 @@ impl Paletted {
pub fn read(r: &mut Reader, strategy: Strategy, global_bits: u8) -> Result<Self> {
let wire = r.u8()?;
let (has_palette, bits) = strategy.configuration(wire, global_bits);
// SimpleBitStorage holds 1 to 32 bits; wider wire values would
// divide by zero or overflow a shift.
if bits > 32 {
return Err(Error::new(format!("{bits} bits per entry, more than 32")));
}
let palette = if !has_palette {
Vec::new()
} else if bits == 0 {
@@ -425,6 +430,23 @@ mod tests {
assert_eq!(g.get(4095), 4095 * 7);
}
#[test]
fn wire_bits_wider_than_storage_are_rejected() {
for wire in [33u8, 200] {
let data = [wire];
let mut r = Reader::new(&data);
assert!(Paletted::read(&mut r, Strategy::Blocks, 16).is_err());
}
// 15- and 16-bit global palettes still read (1024 longs of zeroes).
for (wire, global) in [(15u8, 15u8), (16, 16)] {
let mut data = vec![wire];
data.resize(1 + 1024 * 8, 0);
let mut r = Reader::new(&data);
let p = Paletted::read(&mut r, Strategy::Blocks, global).unwrap();
assert_eq!(p.bits, wire);
}
}
#[test]
fn chunk_data_round_trips() {
let mut states = vec![0; BLOCKS];
+116 -9
View File
@@ -187,6 +187,9 @@ fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
addrs.sort_by_key(SocketAddr::is_ipv6);
addrs
}
/// RFC 8305's connection attempt delay: how long an address has alone before the next
/// one is tried beside it.
const ATTEMPT_DELAY: Duration = Duration::from_millis(250);
/// Both halves before the split.
pub struct Connection {
pub reader: ConnReader,
@@ -206,19 +209,54 @@ impl Connection {
let addrs = crate::resolver::resolve(host, port)?;
Self::connect_addresses(host, addrs, timeout)
}
/// Each address starts once the earlier ones fail or [`ATTEMPT_DELAY`] after the last
/// start, alongside those still trying, and the first to connect wins: a dead address
/// in a round-robin set costs 250 ms instead of the whole timeout.
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
// Report the first address's failure: it is the one vanilla tries, and the IPv6
// fallbacks after it often fail only because the client has no IPv6 route.
let mut first = None;
for addr in ipv4_first(addrs) {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(stream) => return Self::from_stream(stream),
Err(e) => {
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
let addrs = ipv4_first(addrs);
let (send, recv) = std::sync::mpsc::channel();
let mut errors: Vec<Option<std::io::Error>> = addrs.iter().map(|_| None).collect();
let (mut started, mut pending) = (0, 0);
loop {
if let Some(&addr) = addrs.get(started) {
let (send, index) = (send.clone(), started);
let spawned = std::thread::Builder::new()
.name("horizon-connect".into())
.spawn(move || {
let _ = send.send((index, TcpStream::connect_timeout(&addr, timeout)));
});
match spawned {
Ok(_) => pending += 1,
Err(e) => errors[index] = Some(e),
}
started += 1;
}
if pending == 0 {
if started < addrs.len() {
continue;
}
break;
}
let reply = if started < addrs.len() {
match recv.recv_timeout(ATTEMPT_DELAY) {
Ok(reply) => reply,
Err(_) => continue,
}
} else {
recv.recv().expect("a sender is held here")
};
pending -= 1;
match reply {
(_, Ok(stream)) => return Self::from_stream(stream),
(index, Err(e)) => errors[index] = Some(e),
}
}
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
// Report the first address's failure: it is the one vanilla tries, and the IPv6
// fallbacks after it often fail only because the client has no IPv6 route.
match errors.into_iter().flatten().next() {
Some(e) => Err(Error::new(format!("Connection refused: {e}"))),
None => Err(Error::new(format!("Unknown host {host}"))),
}
}
pub fn from_stream(stream: TcpStream) -> Result<Self> {
stream.set_nodelay(true)?;
@@ -281,6 +319,13 @@ impl ConnReader {
loop {
if let Some(frame) = frame::split(&mut self.buffer)? {
let body = frame::decompress(frame, self.threshold)?;
if body.is_empty() {
// A frame with no packet in it. Vanilla's PacketDecoder
// only decodes a packet from a readable buffer, so an
// empty compressed frame is skipped and the connection
// stays up.
continue;
}
let mut r = Reader::new(&body);
let id = r.varint()?;
let start = r.position();
@@ -400,6 +445,42 @@ mod tests {
assert_eq!(peer(&c), v6);
}
#[test]
fn a_dead_address_does_not_hold_up_the_next() {
// TEST-NET-1 never answers (or is unreachable at once).
let dead: SocketAddr = "192.0.2.1:25565".parse().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let live = listener.local_addr().unwrap();
let start = Instant::now();
let c = Connection::connect_addresses("test", vec![dead, live], Duration::from_secs(10))
.unwrap();
assert_eq!(peer(&c), live);
assert!(
start.elapsed() < Duration::from_secs(5),
"{:?}",
start.elapsed()
);
}
#[test]
fn every_failure_reports_the_first_address() {
let closed = |_| {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
};
let error = |host, addrs| {
Connection::connect_addresses(host, addrs, Duration::from_secs(5))
.err()
.unwrap()
.to_string()
};
let refused = error("test", (0..3).map(closed).collect());
assert!(refused.starts_with("Connection refused"), "{refused}");
assert_eq!(error("nowhere", Vec::new()), "Unknown host nowhere");
}
#[test]
fn loopback_with_compression_and_encryption() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -496,4 +577,30 @@ mod tests {
assert_eq!(c.recv().unwrap(), (5, vec![4, 3]));
client.join().unwrap();
}
/// A server may send a compressed frame that carries no packet: the
/// uncompressed length is 0 and the frame holds nothing else. Vanilla
/// skips it (its PacketDecoder only reads a packet from a readable
/// buffer), so the client stays connected.
#[test]
fn an_empty_compressed_frame_is_skipped() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut c = Connection::from_stream(stream).unwrap();
c.set_compression(64);
// The empty frame: its length prefix, then the uncompressed
// length 0 and nothing else.
c.writer.send_contents(&[0]).unwrap();
let mut body = vec![3];
body.extend_from_slice(b"after");
c.writer.send(&body).unwrap();
});
let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
c.set_compression(64);
let (id, payload) = c.recv().unwrap();
assert_eq!((id, payload.as_slice()), (3, &b"after"[..]));
server.join().unwrap();
}
}
+24 -3
View File
@@ -2,7 +2,7 @@
//! zlib compression above a threshold (CompressionEncoder / CompressionDecoder).
use crate::{
Error, Result,
buf::{Reader, varint_size, write_varint},
buf::{Reader, write_varint},
};
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
use std::io::{Read, Write};
@@ -65,6 +65,11 @@ pub fn split(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
};
len |= usize::from(b & 0x7F) << (7 * i);
if b & 0x80 == 0 {
if len == 0 {
// Varint21FrameDecoder: a frame with no bytes is a
// corrupted frame, not an empty packet.
return Err(Error::new("Frame length cannot be zero"));
}
let start = i + 1;
if buffer.len() < start + len {
return Ok(None);
@@ -85,7 +90,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
let mut r = Reader::new(&frame);
let size = r.varint()?;
if size == 0 {
return Ok(frame[1..].to_vec());
return Ok(frame[r.position()..].to_vec());
}
let size = usize::try_from(size).map_err(|_| Error::new("negative uncompressed length"))?;
if size < threshold {
@@ -98,7 +103,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
"Badly compressed packet - size of {size} is larger than protocol maximum"
)));
}
let start = varint_size(size as i32);
let start = r.position();
let mut out = Vec::with_capacity(size);
ZlibDecoder::new(&frame[start..])
.take(size as u64 + 1)
@@ -163,4 +168,20 @@ mod tests {
frame.extend(z.finish().unwrap());
assert!(decompress(frame, Some(256)).is_err());
}
#[test]
fn a_noncanonical_length_prefix_still_reads() {
// The data-length VarInt is read, not measured: zero as `0x80 0x00`.
assert_eq!(
decompress(vec![0x80, 0x00, 7, 1, 2], Some(256)).unwrap(),
[7, 1, 2]
);
// And a compressed packet whose size was written wide: 300.
let body = vec![9; 300];
let mut frame = vec![0xAC, 0x82, 0x00];
let mut z = ZlibEncoder::new(Vec::new(), Compression::default());
z.write_all(&body).unwrap();
frame.extend(z.finish().unwrap());
assert_eq!(decompress(frame, Some(256)).unwrap(), body);
}
}
+42
View File
@@ -31,6 +31,8 @@ pub mod component {
pub const ATTACK_RANGE: i32 = 30;
pub const STORED_ENCHANTMENTS: i32 = 45;
pub const PROFILE: i32 = 72;
pub const BANNER_PATTERNS: i32 = 74;
pub const BASE_COLOR: i32 = 75;
pub const DYED_COLOR: i32 = 47;
pub const MAP_ID: i32 = 48;
pub const MAP_POST_PROCESSING: i32 = 50;
@@ -112,6 +114,10 @@ pub enum Component {
material: i32,
pattern: i32,
},
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
/// registry name: that value stays [`Component::Raw`].
BannerPatterns(Vec<(i32, i32)>),
/// A component Horizon reads past without interpreting it: its encoded
/// bytes, written back as they came.
Raw(Vec<u8>),
@@ -349,6 +355,19 @@ impl Component {
_ => Self::Raw(bytes.to_vec()),
}
}
// DyeColor.STREAM_CODEC: its id.
BASE_COLOR => Self::Int(r.varint()?),
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
// reference's id plus one, or 0 and the pattern itself) and a dye.
BANNER_PATTERNS => {
let start = r.position();
skip::component(BANNER_PATTERNS, r)?;
let bytes = r.since(start);
match Self::banner_layers(bytes) {
Some(layers) => Self::BannerPatterns(layers),
None => Self::Raw(bytes.to_vec()),
}
}
other => {
let start = r.position();
skip::component(other, r)?;
@@ -356,9 +375,32 @@ impl Component {
}
})
}
/// The layers of encoded `BannerPatternLayers` whose patterns are all
/// registry references (`None` when one is direct).
fn banner_layers(bytes: &[u8]) -> Option<Vec<(i32, i32)>> {
let mut r = Reader::new(bytes);
let n = r.count(r.remaining()).ok()?;
let mut layers = Vec::with_capacity(n);
for _ in 0..n {
let pattern = r.varint().ok()?;
let color = r.varint().ok()?;
if pattern < 1 {
return None;
}
layers.push((pattern - 1, color));
}
(r.remaining() == 0).then_some(layers)
}
pub(crate) fn write(&self, kind: i32, w: &mut Writer) {
match self {
Self::Raw(bytes) => w.bytes(bytes),
Self::BannerPatterns(layers) => {
w.varint(layers.len() as i32);
for (pattern, color) in layers {
w.varint(pattern + 1);
w.varint(*color);
}
}
Self::Trim { material, pattern } => {
w.varint(material + 1);
w.varint(pattern + 1);
+20 -2
View File
@@ -13,7 +13,7 @@ const TYPE_SRV: u16 = 33;
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
/// Windows.
/// Windows, where it leaves out the scoped IPv6 ones.
#[cfg(unix)]
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
std::fs::read_to_string("/etc/resolv.conf")
@@ -27,7 +27,7 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
continue;
}
for &ip in adapter.dns_servers() {
for &ip in adapter.dns_servers().iter().filter(|ip| !scoped(**ip)) {
let server = SocketAddr::new(ip, 53);
if !servers.contains(&server) {
servers.push(server);
@@ -42,6 +42,13 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
Vec::new()
}
/// Link-local and site-local IPv6 addresses (such as Windows' `fec0:0:0:ffff::1`
/// defaults), which only work with a scope the adapter's list doesn't carry.
#[cfg(any(windows, test))]
fn scoped(ip: std::net::IpAddr) -> bool {
matches!(ip, std::net::IpAddr::V6(v6) if matches!(v6.segments()[0] & 0xffc0, 0xfe80 | 0xfec0))
}
/// `nameserver` lines of resolv.conf, in order.
#[cfg(any(unix, test))]
fn name_servers(conf: &str) -> Vec<SocketAddr> {
@@ -303,6 +310,17 @@ mod tests {
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
}
#[test]
fn windows_skips_scoped_ipv6_name_servers() {
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
for s in ["fe80::1", "fec0:0:0:ffff::1", "febf::1"] {
assert!(scoped(ip(s)), "{s}");
}
for s in ["1.1.1.1", "2606:4700:4700::1111", "::1", "fc00::1"] {
assert!(!scoped(ip(s)), "{s}");
}
}
/// A local name server: a silent one first, then one that answers truncated
/// over UDP and in full over TCP, like a real server with a big answer.
#[test]
+54
View File
@@ -124,6 +124,45 @@ pub fn is_nightly(tag: &str) -> bool {
tag == NIGHTLY || tag.starts_with("nightly-")
}
/// The version a nightly installs as, from its release title ("Nightly 2026-10-28 · 0c02067"):
/// `nightly-2026-10-28-0c02067`. `published` (GitHub's `published_at`) dates a title without one.
/// The launcher and `horizon-server` name it the same way, and native mods read the commit back.
pub fn nightly_version(title: &str, published: &str) -> String {
let mut words = title
.strip_prefix("Nightly ")
.unwrap_or_default()
.split(" · ");
let date = words
.next()
.filter(|d| is_date(d))
.unwrap_or_else(|| published.get(..10).unwrap_or(published));
let commit = words
.next()
.filter(|c| !c.is_empty() && c.chars().all(|c| c.is_ascii_hexdigit()));
match commit {
Some(commit) => format!("{NIGHTLY}-{date}-{commit}"),
None => format!("{NIGHTLY}-{date}"),
}
}
/// The date of a nightly's version or tag (`nightly-2026-10-28-0c02067`, `nightly-2026-10-28`):
/// nightlies follow every release and each other by it.
pub fn nightly_date(version: &str) -> Option<&str> {
let date = version.strip_prefix("nightly-")?.get(..10)?;
is_date(date).then_some(date)
}
fn is_date(text: &str) -> bool {
text.len() == 10
&& text.chars().enumerate().all(|(i, c)| {
if i == 4 || i == 7 {
c == '-'
} else {
c.is_ascii_digit()
}
})
}
/// The signed SHA-256 of `asset` in release `tag`, or why the download must be refused.
/// `sums` and `signature` are the release's SHA256SUMS and SHA256SUMS.sig, None when it has none.
/// A nightly is never a release: its tags are refused here, whatever their signature
@@ -389,6 +428,21 @@ mod tests {
assert!(is_nightly("nightly-2026-10-27") && !is_nightly("v0.2.81"));
}
#[test]
fn a_nightly_is_named_by_its_date_and_commit() {
let v = nightly_version("Nightly 2026-10-28 · 0c02067", "2026-10-28T20:40:00Z");
assert_eq!(v, "nightly-2026-10-28-0c02067");
assert_eq!(
nightly_version("Something else", "2026-10-29T01:00:00Z"),
"nightly-2026-10-29"
);
assert_eq!(nightly_date(&v), Some("2026-10-28"));
assert_eq!(nightly_date("nightly-2026-10-27"), Some("2026-10-27"));
assert_eq!(nightly_date("nightly"), None);
assert_eq!(nightly_date("v0.2.95"), None);
assert!(is_nightly(&v));
}
#[test]
fn tampering_is_refused() {
let (key, sums, signature) = signed("v2026.10.19");
+1 -1
View File
@@ -305,7 +305,7 @@ impl Shared {
let fetched = (|| {
let current = install.current().unwrap_or_default();
let release = update::release(None)?;
if !update::newer(&release.tag, &current) {
if !update::update_available(&release.tag, &current) {
return Ok(None);
}
shared.update.lock().unwrap().busy =
+2 -1
View File
@@ -401,7 +401,8 @@ fn check_updates(shared: Arc<Shared>) {
thread::sleep(Duration::from_secs(60));
loop {
if let (Ok(release), Some(current)) = (update::release(None), install.current()) {
let newer = update::newer(&release.tag, &current).then_some(release.tag);
let newer = update::update_available(&release.tag, &current)
.then_some(release.tag);
let told = shared.update.lock().unwrap().available.clone();
if let Some(tag) = newer.as_ref().filter(|t| told.as_ref() != Some(*t)) {
note(
+113 -23
View File
@@ -1,9 +1,10 @@
//! Updates and rollbacks (docs/server.md, section 7) for a `horizon-server` installed by install.sh or
//! install.ps1: each release in `<dir>/releases/<tag>`, `<dir>/current` pointing at the one in use, and
//! `<dir>/previous` naming the one before it. An update downloads the newest stable release from GitHub, checks it
//! against the SHA-256 in the release's signed SHA256SUMS (docs/security.md), unpacks it beside the others and
//! switches `current`; the supervisor then restarts the game on it and switches back by itself if the new game
//! doesn't come up.
//! `<dir>/previous` naming the one before it. An update downloads the current Nightly from GitHub (the only build
//! Horizon ships since 2026-10-28; GitHub's latest release while there is none), checks it against the SHA-256 in
//! its signed SHA256SUMS (docs/security.md), unpacks it beside the others as `nightly-<date>-<commit>` and switches
//! `current`; the supervisor then restarts the game on it and switches back by itself if the new game doesn't come
//! up.
use crate::{api::Client, backup, config::Config, engine, supervisor};
use serde_json::{Value, json};
@@ -222,9 +223,10 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
}
}
/// A release on GitHub: its tag and the archive for this computer.
/// A release on GitHub: its name here and the archive for this computer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Release {
/// The release's tag, or for a Nightly the version it installs as (`nightly-2026-10-28-0c02067`).
pub tag: String,
pub asset: String,
pub url: String,
@@ -249,14 +251,21 @@ fn fetch(url: &str) -> Result<Vec<u8>, String> {
Ok(body)
}
/// The newest stable release (or `tag`), from GitHub's API, with the SHA-256 its signed checksums
/// give for this computer's archive.
/// The current Nightly (or `tag`: a release, `nightly` or a dated `nightly-<date>`), from GitHub's
/// API, with the SHA-256 its signed checksums give for this computer's archive. With no Nightly
/// published, GitHub's latest release.
pub fn release(tag: Option<&str>) -> Result<Release, String> {
let url = match tag {
Some(tag) => format!("https://api.github.com/repos/{REPO}/releases/tags/{tag}"),
None => format!("https://api.github.com/repos/{REPO}/releases/latest"),
};
let text = fetch(&url).map_err(|e| match e.as_str() {
let api = |tag: &str| format!("https://api.github.com/repos/{REPO}/releases/tags/{tag}");
let text = match tag {
Some(tag) => fetch(&api(tag)),
None => match fetch(&api(horizon_release_sign::NIGHTLY)) {
Err(e) if e == "404" => fetch(&format!(
"https://api.github.com/repos/{REPO}/releases/latest"
)),
found => found,
},
}
.map_err(|e| match e.as_str() {
"404" => format!("no release {} on GitHub", tag.unwrap_or("")),
_ => e,
})?;
@@ -310,15 +319,31 @@ fn pick(
let sums = attached(horizon_release_sign::SUMS)?;
let signature = attached(horizon_release_sign::SIGNATURE)?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
let sha256 = horizon_release_sign::signed_digest(
tag,
name,
sums.as_deref(),
signature.as_deref(),
keys,
)?;
// A Nightly is signed for its channel, never for a tag, and installs under its date and commit.
let (tag, sha256) = if horizon_release_sign::is_nightly(tag) {
let version = horizon_release_sign::nightly_version(
release["name"].as_str().unwrap_or_default(),
release["published_at"].as_str().unwrap_or_default(),
);
let sha256 = horizon_release_sign::nightly_digest(
name,
sums.as_deref(),
signature.as_deref(),
keys,
)?;
(version, sha256)
} else {
let sha256 = horizon_release_sign::signed_digest(
tag,
name,
sums.as_deref(),
signature.as_deref(),
keys,
)?;
(tag.to_owned(), sha256)
};
Ok(Release {
tag: tag.to_owned(),
tag,
asset: name.to_owned(),
url: asset["browser_download_url"]
.as_str()
@@ -332,10 +357,12 @@ fn pick(
/// tagged `vYYYY.MM.DD[.N]` (any `v` and numbers: `Dated`), compared number by number; from 0.2 they are
/// `vMAJOR.MINOR.PATCH[-PRE]` (`Semver`), and every one of those is newer than every dated one, so
/// v0.2.0 follows v2026.10.19.15. A pre-release (`v0.3.0-rc.1`) comes before its release, as in semver.
/// Nightlies (`nightly-<date>-<commit>`, the only builds since 2026-10-28) follow every release, by date.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
enum TagOrder {
Dated(Vec<u64>),
Semver([u64; 3], Prerelease),
Nightly(String),
}
/// A pre-release's place: (is a release, its identifiers). A release has none and follows every
@@ -353,6 +380,9 @@ enum Identifier {
const FIRST_YEAR: u64 = 1000;
fn tag_order(tag: &str) -> Option<TagOrder> {
if let Some(date) = horizon_release_sign::nightly_date(tag) {
return Some(TagOrder::Nightly(date.to_owned()));
}
let rest = tag.strip_prefix('v')?;
let (core, pre) = match rest.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
@@ -399,6 +429,13 @@ pub fn newer(tag: &str, than: &str) -> bool {
}
}
/// Whether `tag`, the newest build on GitHub, is an update for a server running `current`: a
/// different build that isn't older. Two Nightlies of one day are equal by [`newer`], so the one
/// on GitHub is taken.
pub fn update_available(tag: &str, current: &str) -> bool {
tag != current && tag_order(tag).is_some() && !newer(current, tag)
}
/// Downloads a release's archive into `<root>/downloads/` and checks its SHA-256; the archive's path.
pub fn download(install: &Install, release: &Release) -> Result<PathBuf, String> {
use sha2::Digest;
@@ -599,14 +636,14 @@ pub fn command(dir: &Path, args: &[String]) -> Result<(), String> {
json!({
"installed": current,
"newest": release.tag,
"available": newer(&release.tag, &current),
"available": update_available(&release.tag, &current),
"previous": install.previous(),
})
);
return Ok(());
}
if check {
if newer(&release.tag, &current) {
if update_available(&release.tag, &current) {
println!(
"Horizon {} is out; this server runs {current}. `horizon-server update` installs it.",
release.tag
@@ -619,7 +656,7 @@ pub fn command(dir: &Path, args: &[String]) -> Result<(), String> {
}
return Ok(());
}
if version.is_none() && !newer(&release.tag, &current) {
if version.is_none() && !update_available(&release.tag, &current) {
println!("Already on the newest release ({current}).");
return Ok(());
}
@@ -856,6 +893,59 @@ mod tests {
assert!(newer("v0.2.0", "v0.3.0-rc..1"));
}
#[test]
fn nightlies_follow_every_release() {
let today = "nightly-2026-10-28-0c02067";
assert!(newer(today, "v0.2.95"));
assert!(newer(today, "v2026.10.21"));
assert!(newer(today, "nightly-2026-10-27-1a2b3c4"));
assert!(!newer("v0.2.95", today));
assert!(newer(today, "local"));
// Two builds of one day: neither is newer, but the one on GitHub is the update.
let later = "nightly-2026-10-28-9b772b2";
assert!(!newer(later, today) && !newer(today, later));
assert!(update_available(later, today));
assert!(update_available(today, "v0.2.95"));
assert!(!update_available(today, today));
assert!(!update_available("v0.2.95", today));
assert!(!update_available("nightly-2026-10-27-1a2b3c4", today));
assert!(update_available("v0.2.96", "v0.2.95"));
assert!(!update_available("v0.2.94", "v0.2.95"));
}
#[test]
fn a_nightly_is_picked_with_its_channel_signature_and_named_by_its_build() {
use horizon_release_sign::{SigningKey, sha256_hex, sums_file};
let Some(first) = ASSETS.first() else { return };
let asset = |name: &str| json!({ "name": name, "browser_download_url": format!("https://example.invalid/{name}") });
let (key, _) = SigningKey::generate().unwrap();
let sums = sums_file(&[(first.to_string(), sha256_hex(b"server"))]);
let serving = |signed_for: &str| {
let (sums, signature) = (sums.clone(), key.sign(signed_for, sums.as_bytes()));
move |url: &str| -> Result<Vec<u8>, String> {
match url.rsplit('/').next() {
Some("SHA256SUMS") => Ok(sums.clone().into_bytes()),
Some("SHA256SUMS.sig") => Ok(signature.clone().into_bytes()),
_ => Err("404".into()),
}
}
};
let nightly = json!({
"tag_name": "nightly", "name": "Nightly 2026-10-28 · 0c02067",
"published_at": "2026-10-28T20:40:00Z",
"assets": [asset(first), asset("SHA256SUMS"), asset("SHA256SUMS.sig")],
});
let picked = pick(&nightly, serving("nightly"), &[key.public()]).unwrap();
assert_eq!(picked.tag, "nightly-2026-10-28-0c02067");
assert_eq!(picked.sha256, sha256_hex(b"server"));
// A release's signed list served as the Nightly: refused.
assert!(pick(&nightly, serving("v0.2.95"), &[key.public()]).is_err());
// A Nightly's signed list served as a release: refused.
let mut release = nightly.clone();
release["tag_name"] = json!("v0.2.95");
assert!(pick(&release, serving("nightly"), &[key.public()]).is_err());
}
#[test]
fn pruning_orders_dated_and_semver_releases() {
// `prune` sorts with `newer`: semver releases first, then the dated ones.
+3 -3
View File
@@ -53,8 +53,8 @@ Windows (x64), in PowerShell:
irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
```
The script downloads the latest Horizon release for your computer from GitHub, checks it against the SHA-256
GitHub publishes, and never asks for sudo or administrator rights. It installs:
The script downloads the current Horizon Nightly for your computer from GitHub (the only build Horizon ships;
`--version` picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
- the release in `~/.local/share/horizon/releases/<version>` (Windows: `%LOCALAPPDATA%\Horizon`), with
`current` pointing at the one in use;
@@ -236,7 +236,7 @@ minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, a
after it starts and every 6 hours after, and says so in its log, in `status` and in the panel; it never
updates by itself. `horizon-server update --check` asks once. `horizon-server update`:
1. downloads the newest release and checks it against the SHA-256 GitHub publishes for it;
1. downloads the current Nightly and checks it against its signed SHA-256;
2. stops the game, backs up the world (`backups/<time>-before-<release>.zip`) and switches to the new
release;
3. starts the game on it and waits up to 5 minutes (`--timeout SECONDS`) for it to come up.
+44
View File
@@ -335,9 +335,35 @@ Make an instance: empty but for your options and name, or a copy of another one'
- `NAME`
- `--copy`: The instance to copy (its name or id)
- `--minecraft`: Make a Minecraft: Java Edition instance of this version (1.21.4, latest or latest-snapshot) instead of a Horizon one
MCP: `instance_create`.
## `horizon-launcher instance versions`
The versions of Minecraft: Java Edition a Java instance can play, the newest first.
horizon-launcher instance versions
- `--snapshots`: Snapshots too
- `--old`: Old betas and alphas too
MCP: `instance_versions`.
## `horizon-launcher instance java`
A Java instance's Java: its own Java home or the automatic pick, memory and JVM arguments.
horizon-launcher instance java <INSTANCE>
- `INSTANCE`
- `--home`: A Java home to run it on
- `--auto`: Go back to the Java the version needs, picked or downloaded by the launcher
- `--memory`: The most memory the game may use, in MiB
- `--args`: Extra JVM arguments (one string; "" clears them)
MCP: `instance_java`.
## `horizon-launcher instance rename`
Rename an instance (its folder stays).
@@ -578,6 +604,24 @@ Turn a pack off and move it to the system's trash.
MCP: `packs_remove`, needs `confirm: true`.
## `horizon-launcher java list`
The Java runtimes Horizon downloaded from Mojang and the ones installed on this computer.
horizon-launcher java list
MCP: `java_list`.
## `horizon-launcher java install`
Download Mojang's Java runtime: a major (8, 16, 17, 21, 25) or a component name from a version's JSON (java-runtime-delta).
horizon-launcher java install <JAVA>
- `JAVA`
MCP: `java_install`.
## `horizon-launcher screenshots list`
The screenshots, the newest first, with their files (the default).
+1 -1
View File
@@ -15,7 +15,7 @@ missing; [Rust rewrite status](rust-parity.md) has the details.
## Run from a checkout
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ only for the development
harnesses). Horizon never
harnesses). A checkout never
downloads the game: resource preparation imports **your own copy** (the 26.3
client from your official launcher install, and the 26.3 server jar you
downloaded yourself; see [Build from source](../README.md#build-from-source)),
+36 -4
View File
@@ -19,6 +19,19 @@ builds after its Nightly is replaced. All the mods of one build must pin the sam
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
pin builds on the commit of the installed version the build starts from.
### Mods on the hook points
A mod that hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`: empty
functions the game calls at fixed places, plus helpers, kept stable across releases) can name the version it
needs instead of pinning: `"hooks": 1` in its `.hnmod`'s `horizon-native.json`, the `NATIVE_HOOKS_API` of that
file when it was made. Hook points are only ever added: each addition raises `NATIVE_HOOKS_API`, and a mod builds
on any Horizon whose version is the one it needs or a later one. Its `horizon` is then only the commit it was tested on. The launcher builds it on the commit the other mods
pin, else on the installed version's commit (the game the player plays), after checking that commit's
`NATIVE_HOOKS_API` (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the
mod was tested on. In its own copy of the mod it sets `horizon_revision` to the commit it builds on. Horizon's
official mods (Zoom, FPS counter, Keystrokes, Minimap, Recipes) are made this way, so any of them build together
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
## What the launcher does
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
@@ -32,8 +45,12 @@ trusting native code is the player's decision, never an agent's.
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
links without Visual Studio. The linker is the system's on macOS (the Command Line Tools: the launcher asks for
`xcode-select --install`) and Linux (`cc`). `horizon-launcher rust status|install|remove`.
links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
(`rust_toolchain::MINGW`: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT like `rust-mingw`, one zip pinned by
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring`, `hidapi` and wasmtime compile C,
and `windows-sys`' `raw-dylib` imports need `dlltool` and the assembler it runs. The linker is the system's on
macOS (the Command Line Tools: the launcher asks for `xcode-select --install`) and Linux (`cc`).
`horizon-launcher rust status|install|remove`.
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
3. **The source** of the pinned commit: GitHub's archive of it, once per commit (`native/source/<commit>/`, about
@@ -49,7 +66,10 @@ trusting native code is the player's decision, never an agent's.
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
folder built from the command line, or mods of the default home, go to the **Native mods** instance). That
instance gets each mod's settings from the default home the first time it plays it (its
`horizon-mods/.horizon-mod-data/<id>/` folder and `key.<id>.` lines of `horizon-mods.txt`, where the mod's
WebAssembly version kept them); nothing it already has is overwritten.
## Cache and disk
@@ -70,6 +90,15 @@ Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on N
| Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all |
| A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy |
Measured 2026-10-29 on Windows 11 (Ryzen 7 9800X3D, 16 threads), the same mod and Nightly, an empty `native/target/`
(horizon-mixins, the source and the crates were already downloaded):
| What | Size, time |
|---|---|
| Rust 1.99.1 and MinGW-w64 (`<launcher data>/rust/`) | 1.7 GB, MinGW-w64 875 MB of it |
| Crates in the launcher's Cargo home | 657 MB |
| First build: release profile, the whole game | 6 min 57 s of compiling, 8 min 3 s in all; `native/target/` 2.0 GB |
Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one.
## Trust
@@ -80,6 +109,9 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the
player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
@@ -95,4 +127,4 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes).
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
- Windows and Linux: the toolchains are pinned, but a full game build on them is unproven.
- Linux: the toolchain is pinned, but a full game build on it is unproven. Windows builds and plays (above).
+92 -118
View File
@@ -2,8 +2,9 @@
How a change gets from a pull request to players: the checks a pull request passes, how it merges, and the
automatic releases that follow. Releases are automatic and frequent so the latest Horizon is always playable.
There is one channel ([One channel](#one-channel)): each release is published as the release and GitHub's *latest*
once its smoke test passed and no `release-blocker` is open, and launchers and servers install it at once.
Since 2026-10-28 Horizon ships only the **Nightly** (Ludovic's decision; the Team proposed it): the batch train builds
a Nightly instead of a `vX.Y.Z` release, it is published once its smoke test passed and no `release-blocker` is open,
and every launcher and `horizon-server update` install it ([Nightly only](#nightly-only)).
## Pull request checks
@@ -177,9 +178,9 @@ which runs them on main a batch of merges at a time, on the Mac mini's runner:
the failing harnesses, names the first merge they fail on in the run's summary and comments on its pull request
(`🤖 Agent: parity-main found that this merge breaks ...`). Fix it with a pull request as usual; the next
run checks from the last green commit again.
- Game releases ship only a commit both `checks` and `parity-main` passed on (`tools/release_train.py`, see
below), so a broken merge holds releases instead of reaching players. `release.yml` also starts when a
parity-main run finishes. A forced release (`force=true`) doesn't wait for parity-main.
- Nightlies build only on a commit both `checks` and `parity-main` passed on (`tools/release_train.py`,
`tools/ci/nightly.py`, see below), so a broken merge holds them instead of reaching players. `release.yml` also
starts when a parity-main run finishes. A forced run (`force=true`) doesn't wait for parity-main.
A pull request still runs its own parity job when it changes a harness script, an oracle
(`mods/horizon/src/test/`), a probe (`crates/*/examples/`, `game/*_fixture.rs`, `game/*_harness.rs`), the runner
@@ -188,73 +189,80 @@ A pull request still runs its own parity job when it changes a harness script, a
## Game releases
`.github/workflows/release.yml` releases the game by itself. It starts after every green `checks` or `parity-main`
run of a push to main (it releases a commit only once both passed on it), every 10 minutes (so a release waiting for merges to settle goes out without another merge), and by hand.
`.github/workflows/release.yml` is the batch train: it decides when a new Nightly goes out and starts
`nightly build` (`.github/workflows/nightly-build.yml`, [Nightly channel](#nightly-channel)) on the commit it
decided, which builds, signs and publishes it. It starts after every green `checks` or `parity-main` run of a push to
main, every 10 minutes (so a batch waiting for merges to settle goes out without another merge), and by hand.
```mermaid
flowchart LR
pr(["pull request"]) --> checks{"verify<br/>parity"}
checks -- green --> automerge["auto-merge<br/>(merge commit)"]
automerge --> main(["main"])
main --> mainchecks{"checks on main"}
checks -- green --> merge["merge<br/>(merge commit)"]
merge --> main(["main"])
main --> mainchecks{"checks and<br/>parity-main"}
mainchecks -- green --> decide{"decide<br/>tools/release_train.py"}
cron(["every hour :17"]) --> decide
decide -- "player-facing PRs settled 5 min<br/>(or waiting 20 min), or release-now" --> build
cron(["every 10 minutes"]) --> decide
decide -- "5 player-facing PRs settled 5 min,<br/>the oldest waited 6 h, or release-now" --> nightly["nightly build:<br/>that commit + the reviewers' picks"]
decide -- otherwise --> wait(["wait"])
subgraph build["build, in parallel"]
linux["Linux x86_64<br/>GitHub ubuntu-22.04"]
windows["Windows x64<br/>GitHub windows-latest"]
mac["macOS arm64<br/>Mac mini runner<br/>+ offscreen game run"]
end
build --> gate{"gate: the smoke test drew a world,<br/>no release-blocker is open"}
nightly --> gate{"smoke test drew a world,<br/>no release-blocker is open"}
gate -- no --> nothing(["nothing is published"])
gate -- yes --> publish["publish vX.Y.Z as the release and latest<br/>notes from the merged PRs"]
gate -- yes --> publish["publish the nightly pre-release<br/>notes: what merged since the last one"]
publish --> launcher(["every launcher and server installs it"])
publish --> kira(["Kira posts it; the first release<br/>after 20:00 UTC pings, once a day"])
publish --> kira(["Kira posts it; the first after<br/>20:00 UTC pings, once a day"])
```
### When it releases
`tools/release_train.py decide --kind game` looks at the pull requests merged into main (first-parent merge
commits) since the last **published** game release:
commits) since the commit the published Nightly was built on:
- A pull request counts when it changed what players run: `crates/` (not `examples/`, `tests/`, `benches/`),
`xtask/`, `packaging/`, `Cargo.toml`, `Cargo.lock`, `research-lock.json`, or an exporter `horizon-prepare`
compiles (the `mods/horizon/src/test/java/dev/horizonlab/parity/*.java` files its sources name). Markdown never
counts.
- It releases in batches, so players get a few releases a day rather than one per merge: once 5 pull requests count
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one release), or once the oldest has
- It goes in batches, so players get a few Nightlies a day rather than one per merge: once 5 pull requests count
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one Nightly), or once the oldest has
waited 6 hours, however few they are.
- A pull request labelled `release-now` (a crash or a blocker) releases at once.
- A pull request labelled `release-now` (a crash or a blocker) goes out at once.
- A pull request labelled `skip-release` (work players don't see) doesn't count and isn't listed in the notes.
- An open issue labelled `release-blocker` holds every game release, forced ones too (`held by release-blocker #N`).
- One release at a time (concurrency group `game-release`); a run that finds nothing new does nothing.
- A scheduled or hand-started run releases main's newest commit whose `checks` passed; after `checks`, the commit
they passed on.
- The first release of a new release line (below) goes out at once, without waiting for a pull request.
- An open issue labelled `release-blocker` holds every Nightly, forced ones too (`held by release-blocker #N`);
`nightly build` checks again right before publishing.
- One Nightly build at a time: while one runs or waits, the train starts none. At most 12 builds a UTC day (the
reviewers' `/nightly` builds count): past that the train waits for the next day, except for `release-now` and
`force=true`.
- A scheduled run takes main's newest commit where `checks` and `parity-main` passed; after a run of either, the
commit it passed on, once both passed there.
### Tags
### Nightly only
How releases are numbered, and how installs from before 0.2 reach it: [versioning.md](versioning.md).
Ludovic decided on 2026-10-28 that Horizon ships only the Nightly. No `vX.Y.Z` game release is made any more:
the last one stays GitHub's *latest*, so launchers and servers from before the switch (which don't read Nightlies)
keep a working build until they update themselves. `.github/release-line.txt` still numbers the launcher's releases
(`[email protected]`).
- With a release line in `.github/release-line.txt` (`MAJOR.MINOR`, e.g. `0.2`), a game release is `vMAJOR.MINOR.PATCH`,
the patch after the newest release tagged on that line (`v0.2.0` first). A `feature` label doesn't change the line:
only that file does, by pull request.
- Without the file (every release before 0.2), `vYYYY.MM.DD`, then `.01`, `.02`… the same UTC day.
- Every reader (`release_train.py`, the launcher, `horizon-server update`, the website) ranks
every `vX.Y.Z` tag above every dated one.
- **Launchers** (#3342) install the current `nightly` pre-release as their update, as the version
`nightly-<date>-<commit>`, after checking its checksums signed for the Nightly channel. A player on the newest
release, on a Nightly or on nothing moves to it at the next check; a version picked by hand or pinned by an
instance stays. Worlds are in the game's folder, never in a version's, so nothing touches them. The "Nightly
builds" setting is gone. The last week's Nightlies (`nightly-<date>`) and the old releases stay on the Versions
page.
- **Servers** (#3331): `horizon-server update`, `install.sh` and `install.ps1` follow the Nightly the same way.
- **Kira** posts each Nightly in #releases; the bug template asks for the version the game shows.
- **Everyone gets the reviewers' picks.** A Nightly is main plus the pull requests the trusted reviewers picked
([below](#trusted-reviewers-pick-the-nightlys-pull-requests)), so a pick reaches every player with the next
Nightly. `/nightly clear` makes it main alone.
### Labels
| Label | Effect |
| --- | --- |
| `feature` | Listed under **New** (Kira pings the releases role); makes the next launcher release a minor one |
| `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle |
| `skip-release` | Doesn't trigger a release and isn't listed in the notes |
| `feature` | Listed under **New** in the Nightly's and the launcher's notes |
| `release-now` | A Nightly as soon as main's checks and parity-main pass, past the batch rule and the day's cap |
| `skip-release` | Doesn't trigger a Nightly or a launcher release and isn't listed in the notes |
| `run-parity` | Runs the parity job once on a fork's pull request |
| `full-parity` | Runs the pull request's parity job on playhorizon's runner only (a change every harness reaches) |
| `parity-regression` | Opened by the nightly audit for a harness that fails on main |
| `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) |
| `release-blocker` | On an issue: no Nightly is published while it is open ([When it releases](#when-it-releases)) |
Pull requests without `feature` are listed under **Fixed**. Titles are written for players, since they become
the notes.
@@ -275,51 +283,23 @@ runs the game: GitHub's runners have neither a GPU worth the name nor the game f
### Notes
`tools/release_train.py notes` writes them from the same pull requests:
`tools/ci/nightly.py pick` writes them: what reached main since the previous Nightly (`### New`, label `feature`,
and `### Fixed`, one line per pull request: its title, its number, the issues it closes; `release_train.bullet`),
then the picked pull requests (`### Not merged yet`), `### Known issues` (the bullets of
[docs/known-issues.md](known-issues.md)), thanks to outside contributors, the source commit and the disclaimer. The
title stays `Nightly <date> · <commit>`, which launchers and servers read the version from. Pull request titles
are written for players, since they become the notes.
- the title, `Horizon 0.2.3: <the first pull request titles>` (before 0.2, `Horizon 2026.10.15.01: …`, a day's later releases counting .01, .02…, two digits so GitHub's releases page, which sorts them as text, kept them in order), features first, newest first;
- a line's first release (`X.Y.0`) opens with a paragraph that says so; 0.2.0's says releases are numbered from now
on and how a server that says it is up to date takes it;
- `### New` (label `feature`) and `### Fixed` (the rest), one line per pull request: its title, its number, the
issues it closes;
- `### Known issues`: the bullets of [docs/known-issues.md](known-issues.md);
- thanks to outside contributors, how to install, the disclaimer.
Kira posts every release once in Discord's #releases. It pings the releases role at most once a day: the first game
release published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
Kira posts every Nightly once in Discord's #releases. It pings the releases role at most once a day: the first
Nightly published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
silent (no ping and Discord's @silent flag). Launcher releases post without a ping.
### One channel
Since 2026-10-23 there is one release channel, and it is every launcher's default. Since 2026-10-27 (Ludovic's
decision, revising 2026-10-23's) players may also turn on [Nightly](#nightly-channel), an opt-in pre-release of main
plus pull requests the trusted reviewers picked, not all reviewed yet; nothing about releases changes for anyone who
doesn't. Every game release is published
as the release, never a pre-release, and marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing
promotes it later, the checks that used to gate a promotion run in the release itself, and the publish job
(`tools/release_train.py gate`) publishes nothing unless both pass:
- the release smoke test: the macOS build runs the game offscreen on the Mac mini and keeps its report,
`smoke-test.json`, only when the game drew a world. The gate reads the report again before publishing, and the
release carries it. A run whose macOS build went to GitHub (`MAC_RUNNER=macos-latest`) has no report, so it
publishes nothing;
- no open issue labelled `release-blocker`. `decide` checks first, so a blocked train builds nothing; the gate checks
again right before publishing, so an issue labelled while the builds ran still holds the release. Only the label
and the issue's state count, never its text: whoever opened the issue can edit the text after a maintainer
labels it. Close the issue (or remove the label) and the next run releases everything waiting, with notes
covering all of it.
A failed gate fails the run; the next run (10 minutes later) decides again. Nothing older than the latest release is
ever published: tags only count up.
The launcher has no channel setting any more and looks for releases every 30 minutes. The game pre-releases of the
old Alpha channel (v0.2.1 on, published before this change) stay listed for anyone who wants one by hand, but no
launcher installs them as an update, and `release_train.py` ignores them: the first release of the single channel
lists everything since the last release that was not a pre-release (v0.2.0). Launchers from before this change keep
updating either way: with Alpha (or 0.17's "Beta builds") on they take every release, pre-release or not; with it
off, every release that is not a pre-release, which is now every new one.
`promote.yml` and `tools/release_channels.py` (the daily promotion at 20:00 UTC) are gone.
Before 2026-10-28 game releases were `vX.Y.Z` (numbering: [versioning.md](versioning.md)), each published by this
workflow as the release and GitHub's *latest*. From 2026-10-23 there was one channel (the Alpha pre-releases and the
daily promotion at 20:00 UTC ended: `promote.yml` and `tools/release_channels.py` are gone), and from 2026-10-27 the
Nightly was opt-in beside it. Since 2026-10-28 the one channel is the Nightly ([Nightly only](#nightly-only)).
### Signed checksums
@@ -366,39 +346,29 @@ them with a clear message, and players download the launcher again from the webs
### Starting one by hand
```sh
gh workflow run release --ref <branch> # dry run: builds everything, writes the notes, publishes nothing
gh workflow run release --ref <branch> # dry run: decides and says why, starts nothing
gh workflow run release --ref main -f dry_run=false # the usual decision, now
gh workflow run release --ref main -f dry_run=false -f force=true # release now, whatever the rules (even while paused)
gh workflow run release --ref main -f dry_run=false -f force=true # a Nightly now, whatever the rules (even while paused)
```
A hand-started run on any branch but main is always a dry run. The notes are in the run's summary and its
`release-notes` artifact.
### Pausing
```sh
gh variable set RELEASES_PAUSED --body 1 # no automatic game or launcher releases
gh variable delete RELEASES_PAUSED # back on; the next hourly run catches up
gh variable set RELEASES_PAUSED --body 1 # no automatic Nightly or launcher releases
gh variable delete RELEASES_PAUSED # back on; the next run catches up
```
While paused, runs still decide and say why in their summary; `force=true` still releases, but never past an open
`release-blocker`.
While paused, runs still decide and say why in their summary; `force=true` still starts a Nightly, but never past an
open `release-blocker`. The reviewers' `/nightly` and the 20:17 UTC build still run: a line `paused` in
`.github/trusted-reviewers.txt` stops the reviewers' commands.
### Rolling back
A bad release is withdrawn by turning it back into a draft:
```sh
gh release edit v0.2.3 --draft
```
To stop the next releases while a problem is fixed, label an issue about it `release-blocker`. For the bad
release itself: launchers list only published releases, so the previous version becomes the newest one again, launchers that
didn't install the bad one won't, and players who did can pick the previous version in the launcher's version
list (`launcher/src/backend.rs` tests `withdrawn_release_is_never_offered`). The tag stays, so the next release
that day takes the next number, and its notes start from the last *published* release, so they list the
withdrawn release's changes again with the fix. Usually the better way out is forward: revert the pull request,
label the revert `release-now`, and a fixed release is out as soon as main's checks pass.
To stop the next Nightlies while a problem is fixed, label an issue about it `release-blocker`. The way out is
forward: revert the pull request (or `/nightly remove` a pick), label the revert `release-now`, and a fixed Nightly
is out as soon as main's checks and parity-main pass. Meanwhile a player can pick an earlier build on the launcher's
Versions page (the last week's `nightly-<date>`, or a `vX.Y.Z`), and `horizon-server update --version <tag>` does
the same for a server.
## Launcher releases
@@ -441,13 +411,15 @@ launcher and the website read: both then look the newest launcher tags up one by
## Nightly channel
`.github/workflows/nightly-build.yml` publishes an opt-in build once a night (20:17 UTC) and when a trusted
reviewer asks, for players who want fixes before they are released (#2681). It builds main plus the pull requests the
trusted reviewers picked, merged in their order. With none picked, it builds the newest `union/<agent>-<name>` branch
on origin (main plus the pull requests the contribution gate proved together in one wave) when that union is newer
than main's head and holds something main lacks, otherwise main (`tools/ci/nightly.py pick`). A night with nothing
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
before the gate.
`.github/workflows/nightly-build.yml` builds and publishes the Nightly, the only build players get since
2026-10-28: when the batch train decides ([Game releases](#game-releases), with the commit as `base`), once a night
(20:17 UTC) and when a trusted reviewer asks (#2681). It builds main's newest commit where
`checks` and `parity-main` passed, as a release does (or the `base` input), plus the pull requests the trusted
reviewers picked, merged in their order; with none picked, that commit alone (`tools/ci/nightly.py pick`). No green
commit (main's newest `checks` or `parity-main` run failed) builds nothing. A night with nothing new publishes nothing. Since the Nightly is the version every player installs, a pull request goes in only with an
approving review at the exact head that is built, from a trusted reviewer or from Ludovic's account (his own or his
agents' review), never its author's (Ludovic, 2026-10-28). A push after the review needs a new one: until then the
scheduled build keeps the approved head. `/nightly dry-run` needs no review: it publishes nothing.
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
@@ -491,7 +463,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
/nightly 2640 2642 the set becomes these pull requests, merged onto main in this order; builds now
/nightly add 2650 adds one, or takes its newest head again; builds now
/nightly remove 2640 drops one; builds now
/nightly clear empties the set: back to the proven union or main; builds now
/nightly clear empties the set: back to main alone; builds now
/nightly status what the set holds
/nightly dry-run 2640 builds main + these and signs with a throwaway key; publishes nothing, the set stays
```
@@ -522,14 +494,16 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
- A one-line pull request removing a login from `.github/trusted-reviewers.txt` revokes it as soon as it merges
(the file is his under CODEOWNERS).
- A line `paused` in that file stops every reviewer's `/nightly` (and triage commands); his own still work.
- Main, the gate and releases don't change: picks reach only players who turned Nightly on.
- Main and the gate don't change, but a pick reaches every player with the next Nightly.
- **Jobs.** `pick` chooses or assembles the commit and writes the notes (each pull request in it with its title,
author and who picked it, or for a union the open pull requests it holds beyond main). `build` is
- **Jobs.** `pick` assembles the commit and writes the notes: what reached main since the previous Nightly (New and
Fixed, as a release's notes), then each picked pull request with its title, author and who picked it, the known
issues and the thanks. `build` is
`game-build.yml`, the same builds and smoke test as a release, with the version `nightly-<date>` and
`HORIZON_NIGHTLY="<date> <short sha>"` built in, macOS signed ad hoc. `sign` signs SHA256SUMS for the channel
(the message says `horizon release nightly`, so a nightly's signature never passes for a release's). `publish`
checks the signature and the smoke test (not `release-blocker`: a nightly is how blockers get tested), renames the
checks the signature, the smoke test and that no `release-blocker` is open (as a release; a dry run only warns, so
`/nightly dry-run <pr>` still tries a blocker's fix), renames the
previous `nightly` pre-release to `nightly-<its date>`, publishes the new one as the `nightly` pre-release (never
GitHub's latest), and deletes the `nightly-<date>` pre-releases and tags older than 7 days, nothing else.
- **The signing key never meets the pull requests' code.** Every workflow file and script runs from main's copy (a
@@ -543,9 +517,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
macOS jobs on the runner that runs pull requests' parity jobs (`horizon-game`), never the release runner
(`MAC_RUNNER`), and its Rust cache has its own key and is never saved. A trusted build doesn't use the machine's
shared sccache, which pull requests' jobs write (`HORIZON_NO_SCCACHE`, `tools/ci/runner-env.sh`).
- **Players.** Releases stay the default and the only channel any launcher follows unless its player turns Nightly
on. `release_train.py`, `horizon-server update`, `install.sh` and the launcher's release list skip the tags: none
of them is `vX.Y.Z`.
- **Players.** Every launcher and `horizon-server update` install the `nightly` pre-release
([Nightly only](#nightly-only)). It is never GitHub's latest: the last `vX.Y.Z` stays that, for launchers,
servers and `install.sh` from before the switch.
```sh
gh workflow run "nightly build" # now; skips when the pick is the commit already published
+6 -4
View File
@@ -409,10 +409,12 @@ runtime or new dependency. Server-sent events can replace the polling later if a
newer release a minute after it starts and then every 6 hours, and only says so, in its log, in `status`
and in the panel (`HORIZON_NO_UPDATE_CHECK=1` turns the check off). `update --check` asks GitHub once.
`update`:
1. downloads the newest stable release's archive for this computer (on Linux the server build; `--version`
picks another, a beta included) and checks it against the release's SHA256SUMS, whose Ed25519 signature
must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"): an unsigned
or tampered release is refused;
1. downloads the current Nightly's archive for this computer (the only build Horizon ships since 2026-10-28;
GitHub's latest release while there is none; on Linux the server build; `--version` picks another: a
`vX.Y.Z` release, or a `nightly-<date>` of the last 7 days) and checks it against its SHA256SUMS, whose Ed25519
signature must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"),
signed for the Nightly channel: an unsigned or tampered build is refused. A Nightly installs as
`nightly-<date>-<commit>`, newer than every `vX.Y.Z`; another build of the same day is an update too;
2. unpacks it into `<dir>/releases/<tag>`, beside the others, with its game files linked to
`<dir>/game-files` as the installers do;
3. stops the game, backs up the world (`backups/<time>-before-<tag>.zip`), records the release in use in
+5
View File
@@ -7,6 +7,11 @@ Until 0.2 the game was tagged by date (`v2026.10.19.15`) and the launcher counte
From 0.2 both follow [semantic versioning](https://semver.org) and share one release line: **Horizon 0.2.x** is the
game and the launcher of the 0.2 line.
**Since 2026-10-28 the game ships only Nightlies** ([release-flow.md](release-flow.md#nightly-only)): no new `vX.Y.Z`
game tag is made, and a game build is named `nightly-<date>-<commit>` (its release title, "Nightly <date> ·
<commit>"), which launchers and servers rank after every `vX.Y.Z`. The launcher keeps its `[email protected]` tags on
the release line. What follows describes the `vX.Y.Z` game tags as they were.
## Tags
| | Game | Launcher |
+33 -12
View File
@@ -12,8 +12,9 @@ The opening, then Play ([clip](docs/opening.webm)):
## What it does
- **Versions**: lists the published releases of horizon-rust/horizon, installs the build for
this platform into its own folder, and removes it again.
- **Versions**: lists the published builds of horizon-rust/horizon (the current Nightly, the last
week's, then the releases from before), installs the build for this platform into its own
folder, and removes it again.
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
26.3 server jar from that version's own json, and Java 25) and copies its sounds from your
launcher's asset store (`horizon-prepare sounds`; a launcher install without them only
@@ -31,7 +32,11 @@ The opening, then Play ([clip](docs/opening.webm)):
system's libraries, the asset index and the assets. Every file must have the size and sha1
Mojang lists. The Play page shows how far it is and has Cancel; files already whole are kept,
so the next try goes on from there. **From my install** prepares from the player's own launcher
instead, and an install that lacks the version falls back to the download. Without an owner,
instead, and an install that lacks the version falls back to the download. **Play** on a version
not prepared yet does it all for an owner, as the official launcher does: it prepares from the
install when there is one, else downloads (with Mojang's Java when no Java 25 is installed), then
starts the game. With no install on the computer the Play page offers the download alone and the
Minecraft folder row says none is needed. Without an owner,
the Play page asks to sign in first. `horizon-launcher prepare --download`.
- **Mods**: search Modrinth (CurseForge support is coming soon) for resource
packs and modpacks, each marked Works, Partly or Doesn't run in
@@ -192,6 +197,12 @@ The opening, then Play ([clip](docs/opening.webm)):
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
**Delete** (to the trash, after a confirmation). From the command line:
`horizon-launcher screenshots [list|delete] [--instance <name>]`.
- **Java** (for the Minecraft: Java Edition instances to come): the runtime a version's JSON asks for
(`javaVersion`: its major exactly, Java 8 before that field), found among Mojang's runtimes Horizon
downloaded (`minecraft/runtime/` in its data folder, the one preparing uses) and the Java homes installed
on this computer, read from their `release` files without starting them; when none has that major,
Mojang's runtime is downloaded; an instance may name its own Java home instead.
`horizon-launcher java [list|install]`.
- **Multiplayer**: Home lists the selected instance's servers, the game's own Multiplayer list
(`servers.dat`, each with the picture the game last saw). **Join** starts the game straight into
one; **Add server** and **Remove** (after a confirmation) change the file the game reads,
@@ -212,11 +223,16 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
when the game starts and brings it back to the front when the game closes; `settings
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
downloads, and the launcher's own version and update state.
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
by hand, but are never installed as an update. The `update_channel` and `beta_builds` keys
older launchers wrote in `settings.json` are ignored and dropped on the next save.
- **Updates**: Horizon ships only the Nightly (since 2026-10-28, docs/release-flow.md). The
launcher installs the current one (the `nightly` pre-release, checksums signed for the Nightly
channel) as `nightly-<date>-<commit>` and selects it when the player was on a Nightly, on the
newest release or on nothing; a version picked by hand or pinned by an instance stays. Older
Nightlies are removed once nothing plays them. Worlds are never touched: they live in the game's
folder, not in a version's. The last week's Nightlies (`nightly-<date>`), the `vX.Y.Z`
releases and the old Alpha pre-releases stay on the Versions page to install by hand, never as
an update. The "Nightly builds" setting and its `nightly` key are gone (an old key is ignored);
its "Nightly" instance stays and keeps following the Nightly. The `update_channel` and
`beta_builds` keys older launchers wrote are ignored and dropped on the next save.
- **Game arguments**: extra arguments added to the game's command line (for example
`--server-thread`), one line where quotes group a word with spaces. They are appended after the
launcher's own arguments, kept as `game_args` in `settings.json`, and apply the next time the
@@ -244,11 +260,11 @@ minimize, maximize/restore and close; macOS and Linux keep their system title ba
## Updates
The launcher looks for a newer Horizon release when it starts and every 30 minutes while it
is open, and shows a small "available, Install" line on Play.
The launcher looks for a newer Nightly when it starts and every 30 minutes while it is open, and
shows a small "available, Install" line on Play.
It also updates itself from GitHub releases tagged `[email protected]` (Horizon's own are `vX.Y.Z`;
neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
It also updates itself from GitHub releases tagged `[email protected]` (the game's are `nightly`, and
`vX.Y.Z` before 2026-10-28; neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
every 0.2 tag counts as newer than all of those (docs/versioning.md). It downloads the new build, replaces its
own binary, and offers "Restart" in the footer. Development builds and
`HORIZON_LAUNCHER_NO_SELF_UPDATE=1` never replace themselves.
@@ -297,6 +313,9 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher account offline play without an account
horizon-launcher instance [list] the instances and their game folders (* = Play starts it)
horizon-launcher instance create <name> [--copy <instance>] empty, or a copy of one's worlds, options and packs
horizon-launcher instance create <name> --minecraft <1.21.4|latest|latest-snapshot> a Minecraft: Java Edition instance
horizon-launcher instance versions [--snapshots] [--old] the Java Edition versions, from Mojang's list (kept for offline)
horizon-launcher instance java <instance> [--home <dir>|--auto] [--memory <MiB>] [--args "<jvm args>"]
horizon-launcher instance rename|icon|group <instance> <value> icon: a block or item name, #rrggbb or none
horizon-launcher instance select <instance> make Play start it
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
@@ -306,6 +325,8 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher packs add <file> | on|off|remove <pack> [--kind shader]
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
horizon-launcher java [list] Java runtimes: Mojang's (downloaded by Horizon) and installed ones
horizon-launcher java install <21|java-runtime-delta> downloads Mojang's runtime, each file sha1-checked
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
horizon-launcher multiplayer add <address> [--name <name>] | remove <server>
horizon-launcher modpack [list] the imported packs and their coverage
+2 -7
View File
@@ -99,6 +99,7 @@
"Preparing…": "Préparation…",
"Prepare": "Préparer",
"Prepare again": "Préparer à nouveau",
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Aucun dossier nécessaire : Horizon télécharge Minecraft depuis Mojang pour votre compte.",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.",
"Ready to play": "Prêt à jouer",
"Resources": "Ressources",
@@ -157,13 +158,6 @@
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.",
"Prepared for Minecraft {version}": "Préparé pour Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft.",
"Nightly builds": "Versions Nightly",
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Peut inclure des pull requests pas encore relues, choisies par des relecteurs de confiance ; peut casser.",
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Se joue dans sa propre instance Nightly : vos mondes et la version publiée restent tels quels.",
"Installing the nightly, {percent}%": "Installation de la Nightly, {percent} %",
"The nightly could not be installed: {error}": "La Nightly n'a pas pu être installée : {error}",
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} dans l'instance Nightly. Choisissez Horizon dans la barre latérale pour jouer la version publiée.",
"No nightly build yet.": "Pas encore de version Nightly.",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Cette version du launcher n'a pas de connexion Microsoft (HORIZON_MSA_CLIENT_ID) : elle ne peut pas lancer le jeu.",
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Connectez-vous avec un compte Microsoft qui possède Minecraft: Java Edition pour jouer",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon a besoin d'un compte Microsoft qui possède Minecraft: Java Edition, comme les autres launchers. Il permet aussi de jouer sur les serveurs en ligne avec votre nom et votre skin.",
@@ -175,6 +169,7 @@
"Open {link} in your browser and enter this code:": "Ouvrez {link} dans votre navigateur et entrez ce code :",
"From my install": "Depuis mon installation",
"Download and prepare": "Télécharger et préparer",
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Pas préparé. Jouer télécharge Minecraft {version} depuis Mojang pour votre compte et le prépare.",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
"Cancel": "Annuler",
+2 -7
View File
@@ -99,6 +99,7 @@
"Preparing…": "Подготовка…",
"Prepare": "Подготовить",
"Prepare again": "Подготовить заново",
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Папка не нужна: Horizon скачает Minecraft у Mojang для вашего аккаунта.",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.",
"Ready to play": "Готово к игре",
"Resources": "Ресурсы",
@@ -157,13 +158,6 @@
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.",
"Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft.",
"Nightly builds": "Ночные сборки",
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Может включать ещё не проверенные pull request'ы, выбранные доверенными ревьюерами; что-то может сломаться.",
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Запускается в отдельной сборке Nightly: ваши миры и релиз остаются как есть.",
"Installing the nightly, {percent}%": "Установка ночной сборки, {percent}%",
"The nightly could not be installed: {error}": "Не удалось установить ночную сборку: {error}",
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} в сборке Nightly. Выберите Horizon на боковой панели, чтобы играть в релиз.",
"No nightly build yet.": "Ночных сборок пока нет.",
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "В этой сборке лаунчера нет входа через Microsoft (HORIZON_MSA_CLIENT_ID), поэтому она не может запустить игру.",
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Чтобы играть, войдите в аккаунт Microsoft, у которого есть Minecraft: Java Edition",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Как и другим лаунчерам, Horizon нужен аккаунт Microsoft, у которого есть Minecraft: Java Edition. С ним вы также играете на онлайн-серверах под своим именем и со своим скином.",
@@ -175,6 +169,7 @@
"Open {link} in your browser and enter this code:": "Откройте {link} в браузере и введите этот код:",
"From my install": "Из моей установки",
"Download and prepare": "Скачать и подготовить",
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Не подготовлено. «Играть» скачает Minecraft {version} у Mojang для вашего аккаунта и подготовит его.",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
"Cancel": "Отмена",
+37 -3
View File
@@ -869,6 +869,18 @@ impl Accounts {
/// Plays as `who`: an account, an offline name (kept, only beside an account that owns the
/// game), or the game's own name.
pub fn play_as(&mut self, store: &dyn SecretStore, who: Player) -> Result<(), String> {
self.play_as_with(store, who, false)
}
/// [`Self::play_as`], where `offline_without_owner` (the window's hidden developer option,
/// `backend::Settings::dev_offline`) lets an offline name play without an account that owns
/// the game.
pub fn play_as_with(
&mut self,
store: &dyn SecretStore,
who: Player,
offline_without_owner: bool,
) -> Result<(), String> {
match who {
Player::Microsoft(uuid) => {
if !self.accounts.iter().any(|a| a.uuid == uuid) {
@@ -877,7 +889,7 @@ impl Accounts {
self.selected = Some(uuid);
}
Player::Offline(name) => {
if !self.has_owner(store) {
if !offline_without_owner && !self.has_owner(store) {
return Err(OFFLINE_NEEDS_OWNER.into());
}
let name = self.add_offline(&name)?;
@@ -1054,6 +1066,23 @@ pub fn launch(
}
}
/// The owner gate before the game starts. With the window's hidden developer option
/// (`backend::Settings::dev_offline`, `offline_without_owner`) no owner is no refusal: the game
/// starts offline, without a session and without an `owner`. Downloads never come through here:
/// `backend::download_for_owner` asks [`launch`] itself, so the option never downloads the game.
pub fn launch_for_game(
ep: &Endpoints,
client_id: Option<&str>,
store: &dyn SecretStore,
accounts: &mut Accounts,
offline_without_owner: bool,
) -> Result<Launch, NoOwner> {
match launch(ep, client_id, store, accounts) {
Err(NoOwner) if offline_without_owner => Ok(Launch::default()),
pass => pass,
}
}
/// Write the session for the game, readable by this user only, in a fresh file it deletes
/// when it reads it.
pub fn write_session_file(
@@ -1148,13 +1177,18 @@ impl SignIn {
/// The owner gate for the game about to start, then the selected account's session in a file
/// for it: the file (none: offline) and why the account plays offline this time, if it does.
pub fn session_for_game(game_home: &Path) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
/// `offline_without_owner`: see [`launch_for_game`].
pub fn session_for_game(
game_home: &Path,
offline_without_owner: bool,
) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
let mut accounts = Accounts::load();
let pass = launch(
let pass = launch_for_game(
&Endpoints::live(),
client_id().as_deref(),
store(),
&mut accounts,
offline_without_owner,
)?;
let _ = accounts.save();
let Some((account, secrets)) = pass.session else {
+50 -1
View File
@@ -154,7 +154,11 @@ fn serve(mock: Arc<Mutex<Mock>>) -> Endpoints {
let body = String::from_utf8(body).unwrap();
let (status, text) = {
let mut m = mock.lock().unwrap();
m.log.push((method.clone(), path.clone(), body.clone()));
// A bare `GET /` is a local tool probing every new port (T3 Code does, on the Mac
// mini), not the launcher: no endpoint here is the root.
if (method.as_str(), path.as_str()) != ("GET", "/") {
m.log.push((method.clone(), path.clone(), body.clone()));
}
route(&mut m, &method, &path, &auth, &body)
};
let _ = write!(
@@ -433,6 +437,51 @@ fn offline_names_play_only_beside_an_owner() {
.is_err());
}
#[test]
fn the_developer_option_off_is_the_owner_gate() {
let store = MemoryStore::default();
let mut accounts = Accounts::default();
let refused = accounts.play_as_with(&store, Player::Offline("Builder_1".into()), false);
assert_eq!(refused.unwrap_err(), OFFLINE_NEEDS_OWNER);
assert_eq!(accounts, Accounts::default());
let (_, ep) = mock(|m| m.owns_game = false);
let refused = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, false);
assert_eq!(refused.unwrap_err(), NoOwner);
let mut accounts = signed_in(&store, &secrets("mc-token", true));
let refused = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, false);
assert_eq!(refused.unwrap_err(), NoOwner);
}
#[test]
fn the_developer_option_plays_offline_names_without_an_owner() {
let store = MemoryStore::default();
let mut accounts = Accounts::default();
accounts
.play_as_with(&store, Player::Offline("Builder_1".into()), true)
.unwrap();
assert_eq!(accounts.player(), Player::Offline("Builder_1".into()));
assert_eq!(accounts.name_arg(), Some("Builder_1"));
// The game starts offline: no request, no session, and no owner to download the game for.
let (m, ep) = mock(|_| {});
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
assert!(pass.session.is_none() && pass.owner.is_none() && pass.offline.is_none());
assert!(m.lock().unwrap().log.is_empty());
// Beside an account the services say doesn't own the game: the same, still no owner.
let (_, ep) = mock(|m| m.owns_game = false);
let mut accounts = signed_in(&store, &secrets("mc-token", true));
accounts
.play_as_with(&store, Player::Offline("Builder_1".into()), true)
.unwrap();
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
assert!(pass.session.is_none() && pass.owner.is_none());
assert!(!accounts.has_owner(&store));
// A real owner is still found and brings what it brings without the option.
let (_, ep) = mock(|_| {});
let mut accounts = signed_in(&store, &secrets("mc-token", true));
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
assert_eq!(pass.owner.unwrap().0.name, "Notch");
}
#[test]
fn refusals_have_messages() {
let tokens = || MsTokens {
+302 -32
View File
@@ -37,9 +37,13 @@ pub struct Release {
pub body: String,
pub date: String,
pub asset: Option<(String, u64)>,
/// A GitHub pre-release: the Alpha builds from before every release went out as the release
/// (v0.2.1 on, docs/release-flow.md). Listed and installed by hand, never as an update.
/// Listed and installed by hand, never as an update: a GitHub pre-release of the old Alpha
/// channel (v0.2.1 on), or a Nightly older than the current one.
pub prerelease: bool,
/// A Nightly's GitHub tag (`nightly`, or `nightly-<date>` for the last week's): its files and
/// signature are there, while `tag` is the version it installs as (`nightly-2026-10-28-0c02067`).
/// None for a release.
pub nightly: Option<String>,
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
@@ -154,6 +158,9 @@ pub enum ReleaseOrder {
/// Numbers without trailing zeros, so 1.2 and 1.2.0 are equal.
Before02(Vec<u64>),
Semver([u64; 3], Prerelease),
/// A Nightly (`nightly-<date>-<commit>`), by its date: newer than every release, since the
/// Nightly is the only build Horizon ships.
Nightly(String),
}
/// (is a release, the pre-release's identifiers): a release follows every pre-release of its
@@ -170,6 +177,9 @@ pub enum Identifier {
const FIRST_YEAR: u64 = 1000;
pub fn release_order(tag: &str) -> ReleaseOrder {
if let Some(date) = crate::nightly::date_of(tag) {
return ReleaseOrder::Nightly(date.to_owned());
}
let semver = tag
.strip_prefix(LAUNCHER_TAG_PREFIX)
.or_else(|| tag.strip_prefix('v'))
@@ -270,6 +280,7 @@ pub(crate) fn parse_releases(
.collect(),
asset: found,
prerelease: r["prerelease"].as_bool().unwrap_or(false),
nightly: None,
});
}
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag));
@@ -280,10 +291,30 @@ fn is_launcher_tag(tag: &str) -> bool {
launcher_version(tag).is_some()
}
/// Published Horizon releases, newest first. Drafts never reach the anonymous API, and launcher
/// releases are left out.
/// Published Horizon builds, newest first: the current Nightly (the only build Horizon ships since
/// 2026-10-28, docs/release-flow.md), the last week's Nightlies, then the releases from before.
/// Drafts never reach the anonymous API, and launcher releases are left out.
pub fn releases() -> Result<Vec<Release>, String> {
Ok(parse_releases(&release_list()?, ASSET, is_game_tag))
Ok(game_releases(&release_list()?))
}
/// The game's builds in `list`, newest first. A Nightly is listed under the version it installs as
/// ([`crate::nightly::version_of`]); only the current one (`nightly`) is an update.
pub(crate) fn game_releases(list: &serde_json::Value) -> Vec<Release> {
let mut out = parse_releases(list, ASSET, |tag| {
is_game_tag(tag) || horizon_release_sign::is_nightly(tag)
});
for r in &mut out {
if horizon_release_sign::is_nightly(&r.tag) {
let version = crate::nightly::version_of(r);
let tag = std::mem::replace(&mut r.tag, version);
r.prerelease = tag != horizon_release_sign::NIGHTLY;
r.nightly = Some(tag);
}
}
// Two Nightlies of one day: the current one first (the dated one is the day's earlier build).
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag).then(a.prerelease.cmp(&b.prerelease)));
out
}
/// The launcher releases in `list` (the newest 100 releases). Game releases come out several times
@@ -556,10 +587,17 @@ fn unpack(archive: &Path, url: &str, to: &Path) -> Result<(), String> {
/// the fraction done.
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> {
let tag = release.tag.as_str();
if !is_game_tag(tag) {
return Err(format!("{tag} is not a Horizon release."));
}
let sha256 = signed_digest(tag, ASSET.unwrap_or_default())?;
let sha256 = match &release.nightly {
// Signed for the Nightly channel, served under its own GitHub tag.
Some(published) => crate::signed::nightly_digest(
published,
ASSET.unwrap_or_default(),
&horizon_release_sign::trusted_keys(),
fetch_small,
)?,
None if is_game_tag(tag) => signed_digest(tag, ASSET.unwrap_or_default())?,
None => return Err(format!("{tag} is not a Horizon release.")),
};
install_as(release, tag, &sha256, progress)
}
@@ -1213,8 +1251,9 @@ fn download_for_owner(
fetch: sources::Fetch,
log: &dyn Fn(String),
) -> Result<(), String> {
let v = &pins.version;
let mut accounts = account::Accounts::load();
// The strict gate, never `launch_for_game`: the developer option that lets offline names
// play without an owner must not download the game for them.
let pass = account::launch(
&account::Endpoints::live(),
account::client_id().as_deref(),
@@ -1222,6 +1261,43 @@ fn download_for_owner(
&mut accounts,
);
let _ = accounts.save();
download_for(pins, dir, pass, !accounts.accounts.is_empty(), fetch, log)
}
/// Downloads Minecraft `id` (any version on Mojang's list, its version file's sha1 from it) into
/// Horizon's own game folder, in the official launcher's layout, for an account that owns the
/// game (asked again now). Returns that folder.
pub fn download_version_for_owner(
id: &str,
manifest_sha1: &str,
log: &dyn Fn(String),
) -> Result<PathBuf, String> {
let dir = data_dir().join("minecraft");
let fetch = |url: &str| -> Result<Vec<u8>, String> {
let mut body = Vec::new();
get(url)?
.into_reader()
.read_to_end(&mut body)
.map_err(|e| e.to_string())?;
Ok(body)
};
let pins = sources::Pins::for_version(id, manifest_sha1);
download_for_owner(&pins, &dir, &fetch, log)
.map_err(|e| e.trim_start_matches("\n • ").to_string())?;
Ok(dir)
}
/// What [`download_for_owner`] does with the owner gate's answer: Mojang's files only for its
/// `owner`, an account the services said owns the game just now.
fn download_for(
pins: &sources::Pins,
dir: &Path,
pass: Result<account::Launch, account::NoOwner>,
signed_in: bool,
fetch: sources::Fetch,
log: &dyn Fn(String),
) -> Result<(), String> {
let v = &pins.version;
match pass {
Ok(account::Launch {
owner: Some((owner, _)),
@@ -1246,7 +1322,7 @@ fn download_for_owner(
checked: {}",
pass.offline.unwrap_or_default()
)),
Err(_) if !accounts.accounts.is_empty() => Err(
Err(_) if signed_in => Err(
"\n • No signed-in account owns Minecraft: Java Edition, so Horizon can't download it."
.into(),
),
@@ -1316,7 +1392,7 @@ fn java(
}
/// The dynamic loader glibc programs on x86-64 Linux start through.
const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
pub(crate) const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
/// Runs `cmd` with Java at `home`: JAVA_HOME names it and its `bin` leads the PATH, as the
/// versions' own preparers look for Java.
@@ -1913,8 +1989,10 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
Ok(())
} else {
Err(format!(
"{tag} is not prepared yet. Prepare it first (it needs your own Minecraft {}).",
game_version(tag)
"{tag} is not prepared yet. Prepare it first: `horizon-launcher prepare --version {tag}` \
uses your own Minecraft {v}, or downloads it from Mojang for the signed-in account \
that owns it.",
v = game_version(tag)
))
}
}
@@ -1923,7 +2001,7 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
/// the gate, a sign-in that can't be renewed (no network, expired) still lets the game start,
/// offline.
pub fn client(tag: &str) -> Result<Command, String> {
let (session, offline) = account::session_for_game(&game_home())
let (session, offline) = account::session_for_game(&game_home(), false)
.map_err(|refused| format!("{refused} Sign in with `horizon-launcher account sign-in`."))?;
need_prepared(tag)?;
complete_assets(tag);
@@ -2053,12 +2131,15 @@ pub struct Updates {
pub launcher: Option<Release>,
}
/// The newest Horizon release with a build here, if it is newer than every installed version.
/// The newest Horizon build with a build here (the current Nightly), if it isn't installed and
/// no installed version is newer. Two Nightlies of one day compare equal: the one on GitHub is
/// the update.
pub fn horizon_update(releases: &[Release], installed: &[String]) -> Option<Release> {
let newest = latest(releases)?;
let newer = installed
.iter()
.all(|t| compare_versions(&newest.tag, t).is_gt());
let newer = !installed.contains(&newest.tag)
&& installed
.iter()
.all(|t| compare_versions(&newest.tag, t).is_ge());
newer.then(|| newest.clone())
}
@@ -2098,7 +2179,7 @@ fn releases_and_updates_with(
return Err(e);
}
};
let releases = parse_releases(&list, ASSET, is_game_tag);
let releases = game_releases(&list);
let horizon = horizon_update(&installable_updates(&releases), &installed());
on_releases(Ok(releases));
Ok(Updates {
@@ -2112,12 +2193,18 @@ pub fn check_updates() -> Result<Updates, String> {
releases_and_updates(|_| {})
}
/// Whether a version installed as an update becomes the selected one: only when the player was
/// on the newest installed version before (or had none).
/// Whether a version installed as an update becomes the selected one: when the player had none,
/// was on a Nightly, or on the newest installed release (so a player of the release channel moves
/// to the Nightly at the first check, docs/release-flow.md). An older release picked by hand
/// stays; an instance pinned to a version keeps it either way.
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
match selected {
None => true,
Some(s) => installed_before.first().is_none_or(|newest| newest == s),
Some(s) if crate::nightly::date_of(s).is_some() => true,
Some(s) => installed_before
.iter()
.find(|t| is_game_tag(t))
.is_none_or(|newest| newest == s),
}
}
@@ -2146,6 +2233,9 @@ pub fn install_tag(tag: &str, as_update: bool, progress: impl Fn(f32)) -> Result
settings.selected = Some(release.tag.clone());
settings.save();
}
if as_update && release.nightly.is_some() {
crate::nightly::after_update(&release.tag);
}
Ok(release.tag.clone())
}
@@ -2473,7 +2563,8 @@ pub enum Msg {
Removed(String, Result<(), String>),
Line(Job, String),
Exit(Job, Result<(), String>),
Updates(Result<Updates, String>),
/// Boxed: two releases, the largest message by far.
Updates(Result<Box<Updates>, String>),
LauncherProgress(f32),
LauncherUpdated(Result<(), String>),
/// `reuse_prepared_everywhere` prepared these versions.
@@ -2491,10 +2582,6 @@ pub enum Msg {
PackDone(Result<Box<crate::modpacks::Imported>, String>),
/// How many play Horizon now, or none when the server can't be reached (`telemetry`).
Playing(Option<u64>),
/// The nightly being installed, done this far (`nightly`).
NightlyProgress(f32),
/// A nightly check ended: the version installed (None: already up to date), or why not.
Nightly(Result<Option<String>, String>),
/// Where the native-mods build is (`native_mods::Step::label`).
NativeProgress(String),
/// The native-mods build ended: the version built, or why not.
@@ -2681,14 +2768,16 @@ pub struct Settings {
/// The window's language. Unset follows the system's (`i18n::Language::system`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<crate::i18n::Language>,
/// The player turned Nightly on (`nightly`): the nightly build installs and updates in its own
/// instance. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub nightly: bool,
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
/// a game of their own. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods: bool,
/// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them):
/// offline names play without an account that owns the game, in the window only
/// (`account::Accounts::play_as_with`, `account::launch_for_game`). Never a download: the game still
/// comes from the player's own install. Off by default, and absent from the file while off.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dev_offline: bool,
}
impl Settings {
@@ -2779,6 +2868,107 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
/// The launcher downloads Mojang's game files (client jar, libraries, assets) only for an
/// account the services said owns the game: never without one, and never past the gate by
/// the developer option (`Settings::dev_offline`), whose pass has no owner.
#[test]
fn mojang_downloads_only_for_an_owner() {
let dir = std::env::temp_dir().join(format!("horizon-owner-only-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let pins = sources::Pins::read(&serde_json::json!({ "minecraft": {
"version": "26.3",
"client_sha1": "00",
"manifest_sha1": "00",
}}))
.unwrap();
let asked = std::sync::Mutex::new(Vec::<String>::new());
let fetch = |url: &str| -> Result<Vec<u8>, String> {
asked.lock().unwrap().push(url.to_string());
Err("no network in tests".into())
};
let log = |_: String| {};
let no_owner = || Ok(account::Launch::default());
for (pass, signed_in) in [
(Err(account::NoOwner), false),
(Err(account::NoOwner), true),
(no_owner(), false),
(no_owner(), true),
] {
assert!(download_for(&pins, &dir, pass, signed_in, &fetch, &log).is_err());
}
assert!(asked.lock().unwrap().is_empty());
assert!(!dir.exists());
// An owner: Mojang is asked (and the download fails here, without network).
let owner = account::Account {
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
name: "Notch".into(),
skin: None,
cape: None,
};
let secrets = serde_json::from_str(
r#"{"refresh_token":"r","minecraft_token":"t","minecraft_expires_at":1,"owns":true}"#,
)
.unwrap();
let pass = Ok(account::Launch {
owner: Some((owner, secrets)),
..Default::default()
});
assert!(download_for(&pins, &dir, pass, true, &fetch, &log).is_err());
assert!(!asked.lock().unwrap().is_empty());
let _ = fs::remove_dir_all(&dir);
// In the sources: one call downloads the game, in `download_for`, and the only caller
// of that asks the strict gate. Game-file hosts appear nowhere else.
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = vec![src.clone()];
let mut sources = Vec::new();
while let Some(path) = files.pop() {
if path.is_dir() {
files.extend(fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
} else if path.extension().is_some_and(|e| e == "rs") {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let in_tests = name.contains("tests") || path.parent().unwrap() != src;
// The code alone: test modules (this one included) come after it.
// A Windows checkout has CRLF line ends: the searches below look for "\n".
let mut text = fs::read_to_string(&path).unwrap().replace("\r\n", "\n");
text.truncate(text.find("#[cfg(test)]").unwrap_or(text.len()));
sources.push((name, in_tests, text));
}
}
let calls: Vec<_> = sources
.iter()
.filter(|(_, tests, _)| !tests)
.flat_map(|(name, _, text)| {
text.matches("mojang::download(")
.map(move |_| name.as_str())
})
.collect();
assert_eq!(calls, ["backend.rs"]);
let backend = &sources.iter().find(|(n, ..)| n == "backend.rs").unwrap().2;
let body = |f: &str| {
let start = backend.find(&format!("\nfn {f}(")).unwrap();
&backend[start..start + backend[start + 1..].find("\n}\n").unwrap()]
};
assert!(body("download_for").contains("mojang::download("));
assert!(body("download_for_owner").contains("account::launch("));
assert!(!body("download_for_owner").contains("launch_for_game("));
// `download_for` is defined once and called once, by `download_for_owner`.
assert_eq!(backend.matches("download_for(").count(), 2);
assert!(body("download_for_owner").contains("download_for("));
for host in [
"piston-data",
"libraries.minecraft.net",
"resources.download.minecraft.net",
] {
for (name, tests, text) in &sources {
assert!(
*tests || name == "mojang.rs" || !text.contains(host),
"{name} names {host}"
);
}
}
}
/// A preparation that reuses the imported game files still reads the assets from the
/// launcher that has them: a Prism Launcher instance's are in the launcher root, linked into
/// the official layout (jan.sonki on Discord: silent, grey title screen since 0.2.48).
@@ -3148,6 +3338,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
assert!(launcher_update(&[r(own_tag())]).is_none());
assert_eq!(launcher_version(&own_tag()), Some(VERSION));
@@ -3188,6 +3379,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
let mut list = vec![
r("launcher-v0.16.0"),
@@ -3354,6 +3546,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
let releases = [r("v2026.10.13"), r("v2026.10.12")];
assert_eq!(
@@ -3370,6 +3563,83 @@ mod tests {
assert!(select_after_update(None, &installed));
}
/// The Nightly is the only build Horizon ships (docs/release-flow.md): every launcher takes the
/// current one as its update, over every release; the last week's are listed, never offered.
#[test]
fn every_launcher_updates_to_the_current_nightly() {
let asset = ASSET.unwrap_or("horizon-linux-x86_64.tar.gz");
let build = |url: &str| serde_json::json!([{"name": asset, "browser_download_url": url, "size": 1}]);
let list = serde_json::json!([
{"tag_name": "nightly", "name": "Nightly 2026-10-28 · 9b772b2", "prerelease": true,
"published_at": "2026-10-28T09:58:01Z", "assets": build("n")},
{"tag_name": "[email protected]", "assets": []},
{"tag_name": "v0.2.95", "assets": build("r")},
{"tag_name": "nightly-2026-10-28", "name": "Nightly 2026-10-28 · 83f15fa", "prerelease": true,
"published_at": "2026-10-28T07:54:57Z", "assets": build("d")},
{"tag_name": "nightly-2026-10-27", "name": "Nightly 2026-10-27 · 7954902", "prerelease": true,
"published_at": "2026-10-28T00:15:40Z", "assets": build("e")},
]);
let releases = game_releases(&list);
let listed: Vec<_> = releases
.iter()
.map(|r| (r.tag.as_str(), r.nightly.as_deref(), r.prerelease))
.collect();
assert_eq!(
listed,
[
("nightly-2026-10-28-9b772b2", Some("nightly"), false),
(
"nightly-2026-10-28-83f15fa",
Some("nightly-2026-10-28"),
true
),
(
"nightly-2026-10-27-7954902",
Some("nightly-2026-10-27"),
true
),
("v0.2.95", None, false),
]
);
let offered = |installed: &[&str]| {
let installed: Vec<String> = installed.iter().map(|t| t.to_string()).collect();
horizon_update(&installable_updates(&releases), &installed).map(|r| r.tag)
};
let current = Some("nightly-2026-10-28-9b772b2".to_string());
// A player of the release channel, a new player, one on an older Nightly of the same day.
assert_eq!(offered(&["v0.2.95", "v0.2.94"]), current);
assert_eq!(offered(&[]), current);
assert_eq!(offered(&["nightly-2026-10-28-83f15fa", "v0.2.95"]), current);
assert_eq!(offered(&["nightly-2026-10-28-9b772b2"]), None);
// Nightlies come after every release in every list.
assert!(compare_versions("nightly-2026-10-27-7954902", "v0.2.95").is_gt());
assert!(
compare_versions("nightly-2026-10-28-9b772b2", "nightly-2026-10-27-7954902").is_gt()
);
}
/// Players move to the Nightly at their next check: whoever was on the newest release, on a
/// Nightly, or on nothing. A release picked by hand stays picked.
#[test]
fn players_of_the_newest_release_move_to_the_nightly() {
let installed = [
"nightly-2026-10-27-7954902".to_string(),
"v0.2.95".into(),
"v0.2.90".into(),
];
assert!(select_after_update(Some("v0.2.95"), &installed));
assert!(select_after_update(
Some("nightly-2026-10-27-7954902"),
&installed
));
assert!(select_after_update(None, &installed));
assert!(!select_after_update(Some("v0.2.90"), &installed));
assert!(select_after_update(
Some("v0.2.95"),
&["v0.2.95".to_string()]
));
}
#[test]
fn new_versions_install_automatically_unless_turned_off() {
// No settings file, an old one that never mentioned it, and 0.1.1's default `false`.
+11 -1
View File
@@ -646,7 +646,10 @@ fn install_version(
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
fs::rename(&path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
if let Some(old) = old.as_ref().filter(|o| o.file != relative) {
if let Some(old) = old
.as_ref()
.filter(|o| o.file != relative && !stays(native, &o.file))
{
let _ = fs::remove_file(home.join(&old.file));
}
if project.kind == Kind::Mod {
@@ -674,6 +677,13 @@ fn install_version(
Ok(entry)
}
/// Whether an update to a native version keeps the old file `old`: a mod that turned native
/// keeps its `.hmod` until the player builds the game with the new version. The game runs it
/// meanwhile and skips it once the native one is built in; removing the mod removes both.
fn stays(native: bool, old: &str) -> bool {
native && old.ends_with(".hmod")
}
/// The mod id of an installed mod (`horizon-mods/<id>`, `horizon-mods/<id>.hmod` or
/// `horizon-native-mods/<id>.hnmod`).
pub fn mod_id(entry: &installed::Entry) -> Option<&str> {
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more