Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 266c4576a5 Launcher: java_launch's Windows paths, one part at a time
The log config and old versions' asset folder were joined from
"a/b" strings; on Windows their tests compared those mixed
separators with paths joined part by part. Join them part by part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:21:50 +01:00
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 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 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 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
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 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 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
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
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
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
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
110 changed files with 5715 additions and 964 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"
+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.
+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));
+53 -3
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,
@@ -5053,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;
@@ -5077,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();
@@ -5149,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();
@@ -11573,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]);
@@ -11793,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.
(
@@ -12103,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)
@@ -12762,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
@@ -12909,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
@@ -12981,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 {
@@ -13381,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),
}
}
@@ -13390,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
@@ -13760,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(),
@@ -14234,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() {
+4 -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 {
@@ -873,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() {
@@ -1029,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);
@@ -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",
+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;
}
+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) => {
@@ -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),
}
}
+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 => {}
}
@@ -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,
+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::*;
+1
View File
@@ -1962,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) {
+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 };
+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
};
+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");
}
}
+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);
+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> {
+20
View File
@@ -141,6 +141,26 @@ fn installs_checked_turns_on_and_removes() {
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn a_mod_that_turned_native_keeps_its_hmod_until_removed() {
assert!(stays(true, "horizon-mods/coords_hud.hmod"));
assert!(!stays(false, "horizon-mods/coords_hud.hmod"));
assert!(!stays(true, "horizon-native-mods/coords_hud.hnmod"));
let index = sample();
let home = scratch("turned-native");
install_from(&index, true, None, &hud(&index), &home, &|_, _| {}).unwrap();
// As install_version leaves it after the native update.
let mut list = installed::Installed::load(&home);
list.entries[0].file = "horizon-native-mods/coords_hud.hnmod".into();
list.save(&home).unwrap();
fs::create_dir_all(home.join("horizon-native-mods")).unwrap();
fs::write(home.join("horizon-native-mods/coords_hud.hnmod"), "").unwrap();
installed::remove(&home, "coords_hud").unwrap();
assert!(!home.join("horizon-native-mods/coords_hud.hnmod").exists());
assert!(!home.join("horizon-mods/coords_hud.hmod").exists());
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn installs_a_shader_pack_into_shaderpacks() {
let index = sample();
+4
View File
@@ -261,6 +261,10 @@ pub fn remove(home: &Path, name: &str) -> Result<Entry, String> {
}
}
if let Some(id) = super::horizon::mod_id(&entry).filter(|_| entry.source == SourceId::Horizon) {
// The `.hmod` a mod that turned native left behind (horizon::install_version).
if entry.file.ends_with(".hnmod") {
let _ = fs::remove_file(home.join(super::java::MODS_DIR).join(format!("{id}.hmod")));
}
super::java::set_enabled(home, id, false)?;
}
installed
+136 -7
View File
@@ -23,6 +23,15 @@ struct Cli {
command: Option<Cmd>,
}
#[derive(Subcommand)]
enum JavaCmd {
/// The Java runtimes Horizon downloaded from Mojang and the ones installed on this computer
List,
/// Download Mojang's Java runtime: a major (8, 16, 17, 21, 25) or a component name from a
/// version's JSON (java-runtime-delta)
Install { java: String },
}
#[derive(Subcommand)]
enum RustCmd {
/// Whether it is installed, its version and whether this computer can link with it
@@ -202,6 +211,35 @@ enum InstanceCmd {
/// The instance to copy (its name or id)
#[arg(long)]
copy: Option<String>,
/// Make a Minecraft: Java Edition instance of this version (1.21.4, latest or
/// latest-snapshot) instead of a Horizon one
#[arg(long, conflicts_with = "copy")]
minecraft: Option<String>,
},
/// The versions of Minecraft: Java Edition a Java instance can play, the newest first
Versions {
/// Snapshots too
#[arg(long)]
snapshots: bool,
/// Old betas and alphas too
#[arg(long)]
old: bool,
},
/// A Java instance's Java: its own Java home or the automatic pick, memory and JVM arguments
Java {
instance: String,
/// A Java home to run it on
#[arg(long, conflicts_with = "auto")]
home: Option<PathBuf>,
/// Go back to the Java the version needs, picked or downloaded by the launcher
#[arg(long)]
auto: bool,
/// The most memory the game may use, in MiB
#[arg(long)]
memory: Option<u32>,
/// Extra JVM arguments (one string; "" clears them)
#[arg(long, allow_hyphen_values = true)]
args: Option<String>,
},
/// Rename an instance (its folder stays)
Rename { instance: String, name: String },
@@ -710,6 +748,11 @@ enum Cmd {
#[command(subcommand)]
action: Option<PacksCmd>,
},
/// Java for Minecraft: Java Edition instances: list the runtimes, download Mojang's
Java {
#[command(subcommand)]
action: Option<JavaCmd>,
},
/// The screenshots F2 took in an instance: list, delete
Screenshots {
#[command(subcommand)]
@@ -1327,11 +1370,49 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List { instance: None }), as_json)?,
Cmd::Servers { action } => servers(action.unwrap_or(ServersCmd::List), as_json)?,
Cmd::Rust { action } => rust(action.unwrap_or(RustCmd::Status), as_json)?,
Cmd::Java { action } => java(action.unwrap_or(JavaCmd::List), as_json)?,
Cmd::Mcp => unreachable!(),
}
Ok(())
}
fn java(action: JavaCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::java;
match action {
JavaCmd::List => {
let mut all = java::mojang_runtimes(&java::runtimes_dir());
all.extend(java::installed());
if as_json {
json(serde_json::json!(all));
} else if all.is_empty() {
println!("No Java yet. Horizon downloads Mojang's when an instance needs it.");
} else {
for r in &all {
let from = match r.source {
java::Source::Mojang => "Mojang's, downloaded by Horizon",
java::Source::Installed => "installed",
};
println!("Java {} ({from})\n {}", r.version, r.home.display());
}
}
}
JavaCmd::Install { java: which } => {
let need = java::Need::named(&which)?;
let runtime = java::install(&need, &|line| eprintln!("{line}"))?;
if as_json {
json(serde_json::json!(runtime));
} else {
println!(
"Java {} is ready: {}",
runtime.version,
runtime.home.display()
);
}
}
}
Ok(())
}
fn rust(action: RustCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::rust_toolchain as rust;
match action {
@@ -2082,7 +2163,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
serde_json::json!({
"id": i.id, "name": i.name, "kind": format!("{:?}", i.kind).to_lowercase(),
"home": i.home, "icon": i.icon, "group": i.group, "selected": selected,
"version": i.version, "played_secs": i.played.as_secs(),
"version": i.version, "java": i.java, "played_secs": i.played.as_secs(),
"last_played_ms": i.last_played.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
})
};
@@ -2093,11 +2174,11 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
.as_ref()
.map(|g| format!(" [{g}]"))
.unwrap_or_default();
let pinned = i
.version
.as_ref()
.map(|v| format!(" {v}"))
.unwrap_or_default();
let pinned = match (&i.java, &i.version) {
(Some(java), _) => format!(" Minecraft {} (Java Edition)", java.minecraft),
(None, Some(v)) => format!(" {v}"),
(None, None) => String::new(),
};
let played = match i.last_played {
Some(_) => format!(" {} played", instances::play_time(i.played)),
None => String::new(),
@@ -2126,7 +2207,55 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
all.iter().for_each(|i| line(i, i.id == selected));
}
}
InstanceCmd::Create { name, copy } => changed(instances::create(&name, copy.as_deref())?),
InstanceCmd::Create {
name,
copy,
minecraft: None,
} => changed(instances::create(&name, copy.as_deref())?),
InstanceCmd::Create {
name,
minecraft: Some(version),
..
} => {
let versions = horizon_launcher::minecraft_versions::load()?;
changed(instances::create_java(&name, &versions.find(&version)?.id)?)
}
InstanceCmd::Versions { snapshots, old } => {
let versions = horizon_launcher::minecraft_versions::load()?;
let shown: Vec<_> = versions.of(snapshots, old).collect();
if as_json {
json(serde_json::json!(shown));
} else {
for v in shown {
let date = v.released.get(..10).unwrap_or("");
println!("{} {date} {:?}", v.id, v.kind);
}
}
}
InstanceCmd::Java {
instance,
home,
auto,
memory,
args,
} => {
if instances::find(&instance)?.java.is_none() {
return Err(format!(
"{instance} is a Horizon instance: Horizon is not a Java program."
));
}
changed(instances::edit(&instance, |m| {
let java = m.java.get_or_insert_with(Default::default);
if auto {
java.home = None;
}
java.home = home.or(java.home.take());
java.memory_mb = memory.or(java.memory_mb);
if let Some(args) = args {
java.jvm_args = args.trim().to_string();
}
})?)
}
InstanceCmd::Rename { instance, name } => {
changed(instances::edit(&instance, |m| m.name = Some(name))?)
}
+62
View File
@@ -0,0 +1,62 @@
//! Developer options: a Settings section nothing in the window points to. Seven clicks on the
//! sidebar's "Launcher x.y.z" reveal it for this run; it stays shown while one of its options is
//! on. Its one option lets offline names play without an account that owns the game
//! (`backend::Settings::dev_offline`). The game still comes from the player's own install: the
//! launcher downloads Mojang's files only for an owner (`backend::download_for_owner`).
use super::*;
/// Clicks on the launcher's version that reveal the section.
const CLICKS: u8 = 7;
impl Launcher {
/// A click on the sidebar's launcher version; the last one opens Settings on the section.
pub(super) fn version_clicked(&mut self) {
if self.dev_options {
return;
}
self.dev_clicks += 1;
if self.dev_clicks >= CLICKS {
self.dev_options = true;
self.go(Page::Settings);
}
}
/// The section, when revealed or when one of its options is on.
pub(super) fn dev_options_section(&self, cx: &mut Context<Self>) -> Option<Div> {
if !self.dev_options && !self.settings.dev_offline {
return None;
}
let t = self.theme;
let toggle = switch("dev-offline", self.settings.dev_offline, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.settings.dev_offline = !this.settings.dev_offline;
this.settings.save();
cx.notify();
},
));
Some(
div()
.flex()
.flex_col()
.child(
section_title("Developer options", "terminal", &t)
.mt(px(20.))
.mb(px(12.)),
)
.child(index_row(
0,
"Offline names without an account",
Some(
div()
.child(
"Offline names play without a Microsoft account that owns the game. \
Minecraft still comes from your own install.",
)
.into_any_element(),
),
Some(toggle.into_any_element()),
&t,
)),
)
}
}
+1
View File
@@ -49,6 +49,7 @@ fn maker(root: &Path) -> impl Fn(&str) -> Result<Instance, String> + '_ {
played: Duration::ZERO,
last_played: None,
launch: Default::default(),
java: None,
})
}
}
+62
View File
@@ -101,8 +101,31 @@ pub struct Meta {
/// What runs around the game: a pre-launch command, a wrapper, variables (`launch`).
#[serde(default, skip_serializing_if = "Launch::is_empty")]
pub launch: Launch,
/// Set for a Minecraft: Java Edition instance: it plays the real game, not Horizon.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java: Option<JavaGame>,
}
/// A Minecraft: Java Edition instance: the real game of one version, with the Java it runs on
/// (`crate::java` picks one when `home` is none).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JavaGame {
/// The Minecraft version, as Mojang names it (`1.21.4`).
pub minecraft: String,
/// A Java home to run it on instead of the automatic pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home: Option<PathBuf>,
/// The most memory the game may use, in MiB (`-Xmx`); none: [`DEFAULT_MEMORY_MB`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
/// Extra JVM arguments, split on whitespace.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub jvm_args: String,
}
/// 4 GiB: enough for vanilla and most modpacks, as other launchers start with.
pub const DEFAULT_MEMORY_MB: u32 = 4096;
fn is_zero(n: &u64) -> bool {
*n == 0
}
@@ -146,12 +169,28 @@ pub struct Instance {
pub last_played: Option<SystemTime>,
/// What runs around the game (`launch::wrap`).
pub launch: Launch,
/// The Minecraft: Java Edition it plays, for a Java instance.
pub java: Option<JavaGame>,
}
impl Instance {
/// Ok when it plays Horizon; a Java instance's refusal, until the launcher starts the real
/// game.
fn horizon(&self) -> Result<(), String> {
match &self.java {
None => Ok(()),
Some(java) => Err(format!(
"{} plays Minecraft {} (Java Edition), which this launcher can't start yet: playing \
Java instances comes in a coming update.",
self.name, java.minecraft
)),
}
}
/// The installed version this instance plays: its pinned one, else `requested` (the
/// command line's `--version`) or the launcher's (`backend::resolve_version`).
pub fn resolve_version(&self, requested: Option<&str>) -> Result<String, String> {
self.horizon()?;
match (requested, &self.version) {
(None, Some(pinned)) => backend::resolve_version(Some(pinned)).map_err(|_| {
format!(
@@ -220,6 +259,7 @@ fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
.last_played
.map(|s| UNIX_EPOCH + Duration::from_secs(s)),
launch: meta.launch,
java: meta.java,
id,
kind,
work,
@@ -291,6 +331,19 @@ pub fn create(name: &str, from: Option<&str>) -> Result<Instance, String> {
&backend::data_dir(),
name,
from.as_ref(),
None,
)
}
/// Creates a Minecraft: Java Edition instance named `name` playing `minecraft` (a version id
/// from Mojang's list), with an empty game folder: the real game keeps its own options.
pub fn create_java(name: &str, minecraft: &str) -> Result<Instance, String> {
create_in(
&backend::game_home(),
&backend::data_dir(),
name,
None,
Some(minecraft),
)
}
@@ -299,6 +352,7 @@ fn create_in(
data: &Path,
name: &str,
from: Option<&Instance>,
minecraft: Option<&str>,
) -> Result<Instance, String> {
let name = name.trim();
if name.is_empty() {
@@ -329,6 +383,7 @@ fn create_in(
let dir = game_home.join(OWN).join(&slug);
let made = match from {
Some(from) => copy_home(&from.home, &dir),
None if minecraft.is_some() => fs::create_dir_all(&dir),
None => fs::create_dir_all(&dir).and_then(|()| {
for f in PLAYER_FILES {
let own = game_home.join(f);
@@ -348,6 +403,12 @@ fn create_in(
meta.name = (name != slug).then(|| name.to_string());
meta.icon = from.and_then(|f| f.icon.clone());
meta.group = from.and_then(|f| f.group.clone());
meta.java = from.and_then(|f| f.java.clone()).or_else(|| {
minecraft.map(|v| JavaGame {
minecraft: v.to_string(),
..JavaGame::default()
})
});
store.save(data)?;
find_in(&list_in(game_home, data), &slug)
}
@@ -459,6 +520,7 @@ pub fn play_time(played: Duration) -> String {
/// player's answers that hold everywhere follow it in: data collection and No AI as the
/// launcher has them, and options.txt and the offline name when it has none yet.
pub fn apply(cmd: &mut Command, instance: &Instance) -> Result<(), String> {
instance.horizon()?;
match instance.kind {
Kind::Default => return Ok(()),
Kind::Own => {
+45 -8
View File
@@ -112,8 +112,8 @@ fn existing_worlds_stay_where_they_are_and_every_folder_becomes_an_instance() {
assert_eq!(all[2].home, home.join("ports/small-pack/home"));
// Everything the player can do with instances, then not one byte of theirs has moved.
let empty = create_in(&home, &data, "Survival", None).unwrap();
let copy = create_in(&home, &data, "My copy", Some(&all[0])).unwrap();
let empty = create_in(&home, &data, "Survival", None, None).unwrap();
let copy = create_in(&home, &data, "My copy", Some(&all[0]), None).unwrap();
edit_in(&home, &data, DEFAULT, |m| m.name = Some("Main".into())).unwrap();
edit_in(&home, &data, "Small Pack", |m| {
m.icon = Some(Icon::Texture("diamond".into()));
@@ -160,18 +160,18 @@ fn names_stay_unique_and_packs_are_not_copied() {
let root = scratch("names");
let (home, data) = (root.join("horizon"), root.join("launcher"));
player_home(&home);
create_in(&home, &data, "Survival", None).unwrap();
assert!(create_in(&home, &data, "survival", None).is_err());
assert!(create_in(&home, &data, " ", None).is_err());
create_in(&home, &data, "Survival", None, None).unwrap();
assert!(create_in(&home, &data, "survival", None, None).is_err());
assert!(create_in(&home, &data, " ", None, None).is_err());
// Another name with the same folder name gets the next free folder.
let second = create_in(&home, &data, "Survival!", None).unwrap();
let second = create_in(&home, &data, "Survival!", None, None).unwrap();
assert_eq!(second.id, "survival-2");
assert_eq!(second.name, "Survival!");
assert!(edit_in(&home, &data, "survival-2", |m| m.name =
Some("SURVIVAL".into()))
.is_err());
let port = find_in(&list_in(&home, &data), "small-pack").unwrap();
assert!(create_in(&home, &data, "Pack copy", Some(&port)).is_err());
assert!(create_in(&home, &data, "Pack copy", Some(&port), None).is_err());
assert!(!home.join("instances/pack-copy").exists());
// An empty group is no group.
let grouped = edit_in(&home, &data, "Survival", |m| m.group = Some(" ".into())).unwrap();
@@ -246,7 +246,7 @@ fn an_instance_keeps_its_own_version_until_it_follows_updates_again() {
let root = scratch("version");
let (home, data) = (root.join("horizon"), root.join("launcher"));
player_home(&home);
create_in(&home, &data, "Old world", None).unwrap();
create_in(&home, &data, "Old world", None, None).unwrap();
let pinned = edit_in(&home, &data, "Old world", |m| {
m.version = Some("v0.1.0".into())
})
@@ -297,3 +297,40 @@ fn play_time_reads_as_minutes_then_hours() {
assert_eq!(t(3 * 3600 + 5 * 60), "3 h 05 min");
assert_eq!(t(140 * 3600 + 7 * 60), "140 h");
}
/// A Java instance: an empty folder of its own (the real game keeps its own options), its
/// version in instances.json, kept by a copy, and refused by Play until the launcher starts
/// the real game; Horizon instances' entries stay as they were.
#[test]
fn a_java_instance_plays_one_minecraft_version_in_its_own_folder() {
let root = scratch("java");
let (home, data) = (root.join("horizon"), root.join("launcher"));
player_home(&home);
let java = create_in(&home, &data, "Vanilla 1.21.4", None, Some("1.21.4")).unwrap();
assert_eq!(java.home, home.join(OWN).join("vanilla-1-21-4"));
assert_eq!(fs::read_dir(&java.home).unwrap().count(), 0);
assert_eq!(java.java.as_ref().unwrap().minecraft, "1.21.4");
let copy = create_in(&home, &data, "Second", Some(&java), None).unwrap();
assert_eq!(copy.java, java.java);
let horizon = create_in(&home, &data, "Horizon two", None, None).unwrap();
assert_eq!(horizon.java, None);
let store = fs::read_to_string(data.join(FILE)).unwrap();
assert_eq!(
store.matches("\"minecraft\": \"1.21.4\"").count(),
2,
"{store}"
);
let mut cmd = Command::new("game");
let refused = apply(&mut cmd, &java).unwrap_err();
assert!(
refused.contains("plays Minecraft 1.21.4 (Java Edition)"),
"{refused}"
);
edit_in(&home, &data, &java.id, |m| {
m.java.as_mut().unwrap().memory_mb = Some(6144)
})
.unwrap();
let edited = find_in(&list_in(&home, &data), &java.id).unwrap();
assert_eq!(edited.java.unwrap().memory_mb, Some(6144));
fs::remove_dir_all(&root).unwrap();
}
+275
View File
@@ -0,0 +1,275 @@
//! Java for the real Minecraft: Java Edition (Java instances): which runtime a version needs, the
//! ones on this computer, and Mojang's own when none fits, as other launchers do.
//!
//! A version's JSON names what it runs on: `javaVersion.majorVersion` (21 for 1.21) and the
//! `component` of Mojang's runtime built for it (`java-runtime-delta`). Versions older than that
//! field ran Java 8 (`jre-legacy`). The game wants that major exactly: older versions and their
//! loaders often fail on a newer Java, so a newer one is never picked by itself.
//!
//! Runtimes are found without starting them: each Java home has a `release` file whose
//! `JAVA_VERSION` gives its version. Mojang's are kept in `minecraft/runtime/` in the launcher's
//! data folder (`mojang::java_runtime` lays them out), the folder preparing uses too.
use crate::{backend, mojang, sources};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
/// What a version runs on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Need {
pub major: u32,
/// Mojang's runtime for it, e.g. `java-runtime-delta`.
pub component: String,
}
impl Need {
/// From a version JSON; Java 8 for versions from before `javaVersion`.
pub fn of(version: &serde_json::Value) -> Need {
let java = &version["javaVersion"];
match (java["majorVersion"].as_u64(), java["component"].as_str()) {
(Some(major), Some(component)) => Need {
major: major as u32,
component: component.to_string(),
},
_ => Need {
major: 8,
component: "jre-legacy".into(),
},
}
}
}
/// Mojang's runtime components by the major they carry, newest component of each.
const COMPONENTS: [(u32, &str); 5] = [
(8, "jre-legacy"),
(16, "java-runtime-alpha"),
(17, "java-runtime-gamma"),
(21, "java-runtime-delta"),
(25, "java-runtime-epsilon"),
];
impl Need {
/// A major (`21`) or a component name (`java-runtime-delta`), as typed.
pub fn named(name: &str) -> Result<Need, String> {
let name = name.trim();
let by_major = name.parse::<u32>().ok().and_then(|major| {
COMPONENTS
.iter()
.find(|(m, _)| *m == major)
.map(|(m, c)| (*m, *c))
});
let by_name = COMPONENTS.iter().find(|(_, c)| *c == name).copied();
let (major, component) = by_major.or(by_name).ok_or_else(|| {
let known: Vec<String> = COMPONENTS
.iter()
.map(|(m, c)| format!("{m} ({c})"))
.collect();
format!("Mojang has no Java {name:?}. Known: {}.", known.join(", "))
})?;
Ok(Need {
major,
component: component.to_string(),
})
}
}
/// Where a runtime comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
/// Mojang's, downloaded by Horizon.
Mojang,
/// Installed on this computer (JAVA_HOME, the PATH, the usual folders).
Installed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Runtime {
pub home: PathBuf,
/// Its full version, e.g. `21.0.5`.
pub version: String,
pub major: u32,
pub source: Source,
}
impl Runtime {
/// The `java` program to start the game with.
pub fn java(&self) -> PathBuf {
self.home
.join("bin")
.join(if cfg!(windows) { "javaw.exe" } else { "java" })
}
}
/// The version a Java home's `release` file names (`JAVA_VERSION="21.0.5"`), with its major.
pub fn release_version(home: &Path) -> Option<(String, u32)> {
let text = fs::read_to_string(home.join("release")).ok()?;
let version = text
.lines()
.find_map(|l| l.strip_prefix("JAVA_VERSION="))?
.trim()
.trim_matches('"')
.to_string();
let mut parts = version.split(['.', '_', '-', '+']);
let first: u32 = parts.next()?.parse().ok()?;
let major = if first == 1 {
parts.next()?.parse().ok()?
} else {
first
};
Some((version, major))
}
fn runtime(home: PathBuf, source: Source) -> Option<Runtime> {
let (version, major) = release_version(&home)?;
let found = Runtime {
home,
version,
major,
source,
};
found.java().is_file().then_some(found)
}
/// Mojang's runtimes Horizon downloaded.
pub fn runtimes_dir() -> PathBuf {
backend::data_dir().join("minecraft").join("runtime")
}
/// The runtimes in a Mojang `runtime` folder.
pub fn mojang_runtimes(dir: &Path) -> Vec<Runtime> {
horizon_prepare::java::minecraft_runtimes(dir)
.into_iter()
.filter_map(|home| runtime(home, Source::Mojang))
.collect()
}
/// Java homes installed on this computer: JAVA_HOME, the PATH's, then the folders installers use,
/// each once.
pub fn installed() -> Vec<Runtime> {
let var = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty());
let mut homes: Vec<PathBuf> = var("JAVA_HOME").map(PathBuf::from).into_iter().collect();
if let Some(path) = var("PATH") {
homes.extend(
std::env::split_paths(&path)
.filter(|dir| dir.file_name().is_some_and(|n| n == "bin"))
.filter_map(|dir| fs::canonicalize(dir.join("java")).ok())
.filter_map(|java| Some(java.parent()?.parent()?.to_path_buf())),
);
}
for parent in parents() {
for entry in fs::read_dir(parent).into_iter().flatten().flatten() {
let bundle = entry.path().join("Contents/Home");
homes.push(if bundle.is_dir() {
bundle
} else {
entry.path()
});
}
}
let mut found: Vec<Runtime> = Vec::new();
for home in homes {
let home = fs::canonicalize(&home).unwrap_or(home);
if !found.iter().any(|r| r.home == home) {
found.extend(runtime(home, Source::Installed));
}
}
found
}
/// The folders Java installers put homes in, one home per child.
fn parents() -> Vec<PathBuf> {
let home = dirs::home_dir();
let mut parents: Vec<PathBuf> = match sources::Os::current() {
sources::Os::MacOs => {
let mut p = vec![PathBuf::from("/Library/Java/JavaVirtualMachines")];
p.extend(
home.iter()
.map(|h| h.join("Library/Java/JavaVirtualMachines")),
);
p
}
sources::Os::Linux => ["/usr/lib/jvm", "/usr/java", "/usr/local/lib/jvm"]
.map(PathBuf::from)
.into(),
sources::Os::Windows => ["ProgramFiles", "ProgramW6432"]
.into_iter()
.filter_map(std::env::var_os)
.flat_map(|base| {
let base = PathBuf::from(base);
["Eclipse Adoptium", "Java", "Microsoft", "Zulu", "BellSoft"].map(|v| base.join(v))
})
.collect(),
};
parents.extend(
home.iter()
.flat_map(|h| [h.join(".jdks"), h.join(".sdkman/candidates/java")]),
);
parents
}
/// The runtime for `need`: the Java home an instance names when it names one, else Mojang's
/// runtime of that major, else an installed one of that major. None: Mojang's is to be downloaded.
pub fn pick(
need: &Need,
chosen: Option<&Path>,
mojang: &[Runtime],
installed: &[Runtime],
) -> Result<Option<Runtime>, String> {
if let Some(home) = chosen {
return runtime(home.to_path_buf(), Source::Installed)
.map(Some)
.ok_or_else(|| {
format!(
"{} is not a Java home (no release file or java)",
home.display()
)
});
}
Ok(mojang
.iter()
.chain(installed)
.find(|r| r.major == need.major)
.cloned())
}
/// Downloads Mojang's runtime for `need` into [`runtimes_dir`] (what is already there and whole
/// is kept).
pub fn install(need: &Need, log: &dyn Fn(String)) -> Result<Runtime, String> {
// Mojang builds its Linux Java for glibc: it cannot start without glibc's loader.
if cfg!(all(target_os = "linux", target_arch = "x86_64"))
&& !Path::new(backend::GLIBC_LOADER).exists()
{
return Err(format!(
"Mojang's Java needs glibc ({} is missing; on NixOS, enable programs.nix-ld). Install Java {} and pick it instead.",
backend::GLIBC_LOADER,
need.major
));
}
let fetch = |url: &str| -> Result<Vec<u8>, String> {
let mut body = Vec::new();
std::io::Read::read_to_end(&mut backend::get(url)?.into_reader(), &mut body)
.map_err(|e| e.to_string())?;
Ok(body)
};
let home = mojang::java_runtime(
&need.component,
&runtimes_dir(),
8,
&fetch,
&mojang::CANCEL,
log,
)?;
let home = ["jre.bundle/Contents/Home", ""]
.iter()
.map(|sub| home.join(sub))
.find(|h| h.join("release").is_file())
.unwrap_or(home);
runtime(home, Source::Mojang)
.ok_or_else(|| format!("Mojang's {} has no usable java", need.component))
}
#[cfg(test)]
#[path = "java/tests.rs"]
mod tests;
+135
View File
@@ -0,0 +1,135 @@
//! Fake Java homes in a scratch folder: a `release` file and an empty `bin/java`.
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-java-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
fn fake_home(home: &Path, version: &str) -> PathBuf {
// A Windows home has both: the runtime search looks for java.exe, the game starts javaw.exe.
let programs: &[&str] = if cfg!(windows) {
&["java.exe", "javaw.exe"]
} else {
&["java"]
};
fs::create_dir_all(home.join("bin")).unwrap();
for program in programs {
fs::write(home.join("bin").join(program), b"").unwrap();
}
fs::write(
home.join("release"),
format!("IMPLEMENTOR=\"Fake\"\nJAVA_VERSION=\"{version}\"\nOS_NAME=\"Darwin\"\n"),
)
.unwrap();
home.to_path_buf()
}
fn need(major: u32) -> Need {
Need {
major,
component: format!("c{major}"),
}
}
#[test]
fn a_version_names_its_java_and_old_ones_ran_java_8() {
let new =
serde_json::json!({"javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}});
assert_eq!(
Need::of(&new),
Need {
major: 21,
component: "java-runtime-delta".into()
}
);
let old = serde_json::json!({"id": "1.12.2"});
assert_eq!(
Need::of(&old),
Need {
major: 8,
component: "jre-legacy".into()
}
);
}
#[test]
fn the_release_file_gives_the_version_java_8_included() {
let dir = scratch("release");
for (version, major) in [
("21.0.5", 21),
("1.8.0_432", 8),
("25", 25),
("17.0.12+7", 17),
] {
let home = fake_home(&dir.join(version), version);
assert_eq!(release_version(&home), Some((version.to_string(), major)));
}
assert_eq!(release_version(&dir.join("none")), None);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn mojangs_runtimes_are_found_in_their_layout() {
let dir = scratch("mojang");
let platform = mojang::java_platform().unwrap_or("linux");
let delta = fake_home(
&dir.join(format!("java-runtime-delta/{platform}/java-runtime-delta")),
"21.0.5",
);
// macOS runtimes keep their home inside a bundle.
let legacy = fake_home(
&dir.join(format!(
"jre-legacy/{platform}/jre-legacy/jre.bundle/Contents/Home"
)),
"1.8.0_432",
);
let found = mojang_runtimes(&dir);
let homes: Vec<_> = found.iter().map(|r| (r.home.clone(), r.major)).collect();
assert_eq!(homes, [(delta, 21), (legacy, 8)]);
assert!(found.iter().all(|r| r.source == Source::Mojang));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_pick_is_the_exact_major_mojangs_first_never_a_newer_one() {
let dir = scratch("pick");
let rt = |name: &str, version: &str, source| {
runtime(fake_home(&dir.join(name), version), source).unwrap()
};
let mojang = [rt("m21", "21.0.5", Source::Mojang)];
let installed = [
rt("i25", "25.0.1", Source::Installed),
rt("i21", "21.0.3", Source::Installed),
rt("i17", "17.0.12", Source::Installed),
];
let picked = |n| pick(&need(n), None, &mojang, &installed).unwrap();
assert_eq!(picked(21).unwrap().home, dir.join("m21"));
assert_eq!(picked(17).unwrap().home, dir.join("i17"));
// Java 8 is needed and only newer ones are here: Mojang's is to be downloaded.
assert_eq!(picked(8), None);
assert_eq!(pick(&need(8), None, &[], &[]).unwrap(), None);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_instances_own_java_wins_and_a_wrong_folder_says_so() {
let dir = scratch("own");
let own = fake_home(&dir.join("own"), "17.0.12");
let picked = pick(&need(21), Some(&own), &[], &[]).unwrap().unwrap();
assert_eq!((picked.home, picked.major), (own, 17));
assert!(pick(&need(21), Some(&dir.join("nothing")), &[], &[])
.unwrap_err()
.contains("is not a Java home"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_typed_java_is_a_major_or_a_component() {
assert_eq!(Need::named("21").unwrap().component, "java-runtime-delta");
assert_eq!(Need::named(" jre-legacy ").unwrap().major, 8);
assert!(Need::named("11")
.unwrap_err()
.contains("Known: 8 (jre-legacy)"));
}
+322
View File
@@ -0,0 +1,322 @@
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
//! official launcher reads it: the main class, the classpath of this system's libraries and the
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
//!
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
//! one game folder. Nothing here reaches the network.
use crate::mojang;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
pub struct Player {
pub name: String,
pub uuid: String,
pub token: String,
pub xuid: String,
}
/// Everything the command needs besides the version JSON.
pub struct Launch<'a> {
/// Holds `versions/`, `libraries/`, `assets/`.
pub minecraft: &'a Path,
/// The instance's game folder (saves, options.txt, mods).
pub game_dir: &'a Path,
/// Where an old version's natives are unpacked.
pub natives: &'a Path,
/// The `java` program.
pub java: &'a Path,
pub memory_mb: u32,
/// The player's own JVM arguments.
pub jvm_args: &'a [String],
pub player: &'a Player,
/// `osx`, `linux` or `windows`, as version files name systems.
pub os: &'a str,
}
/// This system's name in version files.
pub fn os_name() -> &'static str {
match crate::sources::Os::current() {
crate::sources::Os::Linux => "linux",
crate::sources::Os::MacOs => "osx",
crate::sources::Os::Windows => "windows",
}
}
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
/// Every optional feature (demo, custom resolution, quick play) is off.
fn allowed(rules: &Value, os: &str) -> bool {
let Some(rules) = rules.as_array() else {
return true;
};
let arch_matches = |arch: &str| match arch {
"x86" => cfg!(target_arch = "x86"),
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
_ => false,
};
let mut allow = false;
for rule in rules {
let system = &rule["os"];
let matches = system["name"].as_str().is_none_or(|n| n == os)
&& system["arch"].as_str().is_none_or(arch_matches)
&& rule["features"]
.as_object()
.is_none_or(|f| f.values().all(|v| v == false));
if matches {
allow = rule["action"] == "allow";
}
}
allow
}
/// The libraries this system uses, then the client jar.
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let mut paths: Vec<PathBuf> = Vec::new();
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
let path = minecraft.join("libraries").join(mojang::relative(path)?);
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths.push(
minecraft
.join("versions")
.join(mojang::relative(id)?)
.join(format!("{id}.jar")),
);
Ok(paths)
}
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
pub fn unpack_natives(
version: &Value,
minecraft: &Path,
os: &str,
to: &Path,
) -> Result<(), String> {
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
let Some(key) = mojang::native_classifier(library, os) else {
continue;
};
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
continue;
};
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
let exclude: Vec<&str> = library["extract"]["exclude"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let Some(name) = entry.enclosed_name() else {
continue;
};
let text = name.to_string_lossy().replace('\\', "/");
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
continue;
}
let out = to.join(&name);
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
/// the objects copied there once; the asset store itself for every other version.
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
let assets = minecraft.join("assets");
let index = assets
.join("indexes")
.join(mojang::relative(&format!("{id}.json"))?);
let Ok(text) = fs::read_to_string(&index) else {
return Ok(assets);
};
let index: Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
let to = if index["map_to_resources"] == true {
game_dir.join("resources")
} else if index["virtual"] == true {
assets.join("virtual").join(mojang::relative(id)?)
} else {
return Ok(assets);
};
for (name, object) in index["objects"].as_object().into_iter().flatten() {
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
continue;
};
let out = to.join(mojang::relative(name)?);
if !out.is_file() {
let from = assets.join("objects").join(&hash[..2]).join(hash);
fs::create_dir_all(out.parent().unwrap())
.and_then(|_| fs::copy(&from, &out))
.map_err(|e| format!("{}: {e}", from.display()))?;
}
}
Ok(to)
}
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
fn list(args: &Value, os: &str) -> Vec<String> {
let mut out = Vec::new();
for arg in args.as_array().into_iter().flatten() {
match arg {
Value::String(s) => out.push(s.clone()),
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
Value::String(s) => out.push(s.clone()),
Value::Array(values) => {
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
}
_ => {}
},
_ => {}
}
}
out
}
/// `${name}` replaced by its value; an unknown one stays as written.
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
let mut out = String::new();
let mut rest = arg;
while let Some(start) = rest.find("${") {
let Some(len) = rest[start..].find('}') else {
break;
};
out.push_str(&rest[..start]);
let key = &rest[start + 2..start + len];
match vars.get(key) {
Some(value) => out.push_str(value),
None => out.push_str(&rest[start..start + len + 1]),
}
rest = &rest[start + len + 1..];
}
out.push_str(rest);
out
}
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
/// Unpacks natives and lays out old assets first.
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let main = version["mainClass"]
.as_str()
.ok_or("The version file has no mainClass")?;
let separator = if cfg!(windows) { ";" } else { ":" };
let classpath = classpath(version, launch.minecraft, launch.os)?;
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
let assets = launch.minecraft.join("assets");
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
let path = |p: &Path| p.to_string_lossy().into_owned();
let p = launch.player;
let vars: HashMap<&str, String> = HashMap::from([
("auth_player_name", p.name.clone()),
("auth_uuid", p.uuid.clone()),
("auth_access_token", p.token.clone()),
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
("auth_xuid", p.xuid.clone()),
("clientid", String::new()),
("user_type", "msa".into()),
("user_properties", "{}".into()),
("version_name", id.to_string()),
(
"version_type",
version["type"].as_str().unwrap_or("release").into(),
),
("game_directory", path(launch.game_dir)),
("assets_root", path(&assets)),
("game_assets", path(&game_assets)),
(
"assets_index_name",
version["assetIndex"]["id"]
.as_str()
.unwrap_or("legacy")
.into(),
),
("natives_directory", path(launch.natives)),
(
"library_directory",
path(&launch.minecraft.join("libraries")),
),
("classpath_separator", separator.into()),
(
"classpath",
classpath
.iter()
.map(|c| path(c))
.collect::<Vec<_>>()
.join(separator),
),
("launcher_name", "horizon-launcher".into()),
("launcher_version", env!("CARGO_PKG_VERSION").into()),
]);
let mut jvm = vec![
format!("-Xmx{}M", launch.memory_mb),
// Whatever the logging setup, never look names up in log messages.
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
];
let logging = &version["logging"]["client"];
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
{
let config = launch
.minecraft
.join("assets")
.join("log_configs")
.join(mojang::relative(file)?);
jvm.push(arg.replace("${path}", &path(&config)));
}
let (jvm_list, game) = match version["arguments"].as_object() {
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
None => (
vec![
"-Djava.library.path=${natives_directory}".into(),
"-cp".into(),
"${classpath}".into(),
],
version["minecraftArguments"]
.as_str()
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect(),
),
};
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
jvm.extend(launch.jvm_args.iter().cloned());
let mut cmd = Command::new(launch.java);
cmd.current_dir(launch.game_dir)
.args(&jvm)
.arg(main)
.args(game.iter().map(|a| fill(a, &vars)));
Ok(cmd)
}
#[cfg(test)]
#[path = "java_launch/tests.rs"]
mod tests;
+215
View File
@@ -0,0 +1,215 @@
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
//! a scratch folder.
use super::*;
use serde_json::json;
use std::io::Write;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
/// `rel` (`a/b/c`) under `base`, one part at a time as the code joins them (Windows paths).
fn under(base: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(base.to_path_buf(), |path, part| path.join(part))
}
fn player() -> Player {
Player {
name: "Notch".into(),
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
token: "mc-token".into(),
xuid: "2535400000000000".into(),
}
}
fn args(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
fn modern() -> Value {
json!({
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "19"},
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
"--userType", "${user_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
"-cp", "${classpath}"]
},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
"file": {"id": "client-1.21.2.xml"}}},
"libraries": [
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
]
})
}
#[test]
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
let dir = scratch("modern");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let java = dir.join("java");
let p = player();
let extra = vec!["-XX:+UseZGC".to_string()];
let launch = |os| Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 6144,
jvm_args: &extra,
player: &p,
os,
};
let cmd = command(&modern(), &launch("osx")).unwrap();
let a = args(&cmd);
let at = |s: &str| {
a.iter()
.position(|x| x == s)
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
};
assert_eq!(a[0], "-Xmx6144M");
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
let config = under(&mc, "assets/log_configs/client-1.21.2.xml");
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
assert_eq!(a[at("--username") + 1], "Notch");
assert_eq!(a[at("--accessToken") + 1], "mc-token");
assert_eq!(a[at("--assetIndex") + 1], "19");
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
assert_eq!(a[at("--userType") + 1], "msa");
// Features are off: no demo, no custom size.
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
let sep = if cfg!(windows) { ";" } else { ":" };
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
let lib = |p: &str| {
under(&mc.join("libraries"), p)
.to_string_lossy()
.into_owned()
};
assert_eq!(
cp,
[
lib("a/common/1/common-1.jar"),
lib("a/mac/1/mac-1.jar"),
under(&mc, "versions/1.21.4/1.21.4.jar")
.to_string_lossy()
.into_owned()
]
);
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
let linux = args(&command(&modern(), &launch("linux")).unwrap());
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
// The x86-only rule holds on 32-bit Windows alone.
let windows = args(&command(&modern(), &launch("windows")).unwrap());
assert_eq!(
windows.contains(&"-Xss1M".into()),
cfg!(target_arch = "x86")
);
let _ = fs::remove_dir_all(&dir);
}
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
/// virtual asset index laid out by name.
#[test]
fn an_old_version_gets_its_natives_and_assets_by_name() {
let dir = scratch("old");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
let opts = zip::write::SimpleFileOptions::default();
for (name, body) in [
("liblwjgl.dylib", b"lib".as_slice()),
("META-INF/MANIFEST.MF", b"m"),
] {
zip.start_file(name, opts).unwrap();
zip.write_all(body).unwrap();
}
zip.finish().unwrap();
let hash = "0123456789abcdef0123456789abcdef01234567";
let object = mc.join("assets/objects/01").join(hash);
fs::create_dir_all(object.parent().unwrap()).unwrap();
fs::write(&object, b"ogg").unwrap();
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
fs::write(
mc.join("assets/indexes/1.8.json"),
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
.to_string(),
)
.unwrap();
let version = json!({
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "1.8"},
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
"libraries": [{
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
"natives": {"osx": "natives-osx"},
"extract": {"exclude": ["META-INF/"]},
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
}]
});
let (java, p) = (dir.join("java"), player());
let launch = Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 2048,
jvm_args: &[],
player: &p,
os: "osx",
};
let a = args(&command(&version, &launch).unwrap());
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
assert!(!natives.join("META-INF").exists());
let virtual_dir = under(&mc, "assets/virtual/1.8");
assert_eq!(
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
b"ogg"
);
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
assert_eq!(
a[at("--session") + 1],
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
);
assert_eq!(a[at("--userProperties") + 1], "{}");
assert_eq!(a[at("--unknown") + 1], "${nothing}");
assert_eq!(a[0], "-Xmx2048M");
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
// Natives are not on the classpath; the client jar is.
let cp = &a[at("-cp") + 1];
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn placeholders_fill_in_place() {
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
}
+3
View File
@@ -14,7 +14,10 @@ pub mod heads;
pub mod i18n;
pub mod import;
pub mod instances;
pub mod java;
pub mod java_launch;
pub mod launch;
pub mod minecraft_versions;
pub mod modpacks;
pub mod mojang;
pub mod native_mods;
+126 -142
View File
@@ -13,6 +13,7 @@ mod console;
mod console_page;
mod cull;
mod datapacks_page;
mod dev_options;
mod export_page;
mod game_tools;
mod import_page;
@@ -54,7 +55,6 @@ use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Upda
use horizon_launcher::i18n::{self, tr, trf};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
use horizon_launcher::nightly;
use horizon_launcher::telemetry;
use scrollbar::Scrollbar;
use std::borrow::Cow;
@@ -171,12 +171,6 @@ struct Launcher {
/// nothing can launch, select or prepare it, and its row can't install or remove it again.
removing: Option<String>,
install_error: Option<(String, String)>,
/// Nightly (`horizon_launcher::nightly`): how far the nightly being installed is, and why the
/// last check or install failed.
nightly_progress: Option<f32>,
nightly_error: Option<String>,
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
nightly_select: bool,
/// Native mods (`native_mods`): the build's step while it runs, why it failed, and the
/// warning before a build while it is open, with the mods it names.
native_step: Option<String>,
@@ -219,8 +213,14 @@ struct Launcher {
owner: Option<bool>,
/// Play was refused (or Home's Sign in pressed): the owner gate's screen is open.
owner_screen_open: bool,
/// Clicks on the sidebar's launcher version, and whether they revealed the developer options
/// (`dev_options`) for this run.
dev_clicks: u8,
dev_options: bool,
/// The game is waiting for its session (a token renewal) before it starts.
starting_client: Option<String>,
/// Play was pressed on a version not prepared yet: it starts once this preparation ends well.
play_when_prepared: Option<String>,
/// Join a friend: the code typed on the Play page, and the one the game
/// starting now joins.
friend_code: String,
@@ -392,9 +392,6 @@ impl Launcher {
installing: None,
removing: None,
install_error: None,
nightly_progress: None,
nightly_error: None,
nightly_select: false,
native_step: None,
native_error: None,
native_confirm: None,
@@ -428,7 +425,10 @@ impl Launcher {
keychain: true,
owner: None,
owner_screen_open: false,
dev_clicks: 0,
dev_options: false,
starting_client: None,
play_when_prepared: None,
friend_code: String::new(),
friend_focused: false,
friend_focus: cx.focus_handle(),
@@ -522,7 +522,7 @@ impl Launcher {
self.checked = Some(Instant::now());
let tx = self.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(Msg::Updates(backend::check_updates()));
let _ = tx.send(Msg::Updates(backend::check_updates().map(Box::new)));
});
}
@@ -565,30 +565,6 @@ impl Launcher {
self.install(&release);
}
/// With Nightly on, installs the current nightly in the Nightly instance if it is new.
fn update_nightly(&mut self) {
if !self.settings.nightly || self.nightly_progress.is_some() {
return;
}
self.nightly_progress = Some(0.);
self.nightly_error = None;
let (tx, installed) = (self.tx.clone(), self.installed.clone());
std::thread::spawn(move || {
let result = nightly::latest().and_then(|latest| {
let Some(release) = nightly::update(latest, &installed) else {
return Ok(None);
};
let progress = |p| {
let _ = tx.send(Msg::NightlyProgress(p));
};
nightly::install(&release, progress).map(|_| Some(nightly::version_of(&release)))
});
let _ = tx.send(Msg::Nightly(result));
});
}
/// Turns Nightly on (install it, then play its instance) or off (play the release again; the
/// Nightly instance and its worlds stay).
/// The native mods turned on in the instance Play starts, unpacked and checked, with their
/// bundles: what the warning names and the build compiles.
fn native_mods_to_build(
@@ -639,17 +615,6 @@ impl Launcher {
});
}
fn set_nightly(&mut self, on: bool) {
self.settings.nightly = on;
if on {
self.nightly_select = true;
} else if self.settings.instance.as_deref() == Some(nightly::INSTANCE) {
self.settings.instance = None;
}
self.settings.save();
self.update_nightly();
}
fn fetch_releases(&mut self) {
self.releases = Load::Loading;
let tx = self.tx.clone();
@@ -665,7 +630,7 @@ impl Launcher {
let updates = backend::releases_and_updates(|releases| {
let _ = tx.send(Msg::Releases(releases));
});
let _ = tx.send(Msg::Updates(updates));
let _ = tx.send(Msg::Updates(updates.map(Box::new)));
});
}
@@ -721,7 +686,10 @@ impl Launcher {
continue;
}
};
self.owner = Some(true);
// Past the gate by the developer option alone is no owner.
if !self.settings.dev_offline {
self.owner = Some(true);
}
if let Some(file) = file {
cmd.arg("--session-file").arg(file);
}
@@ -826,17 +794,15 @@ impl Launcher {
}
Msg::Updates(Ok(updates)) => {
self.update_error = None;
self.updates = updates;
self.updates = *updates;
if self.settings.auto_install() {
self.install_update();
}
if let Some(release) = self.updates.launcher.clone() {
self.update_launcher(release);
}
self.update_nightly();
}
Msg::Updates(Err(e)) => self.update_error = Some(e),
Msg::NightlyProgress(p) => self.nightly_progress = Some(p),
Msg::NativeProgress(step) => self.native_step = Some(step),
Msg::Native(result) => {
self.native_step = None;
@@ -846,28 +812,6 @@ impl Launcher {
self.native_error = Some(e);
}
}
Msg::Nightly(result) => {
self.nightly_progress = None;
self.installed = backend::installed();
self.reload_instances();
match result {
Ok(_)
if self.nightly_select
&& self.settings.nightly
&& self
.instances
.list
.iter()
.any(|i| i.id == nightly::INSTANCE) =>
{
self.nightly_select = false;
self.settings.instance = Some(nightly::INSTANCE.into());
self.settings.save();
}
Ok(_) => {}
Err(e) => self.nightly_error = Some(e),
}
}
Msg::LauncherProgress(p) => {
if let SelfUpdate::Downloading(_, f) = &mut self.self_update {
*f = p;
@@ -911,6 +855,13 @@ impl Launcher {
match job {
Job::Prepare => {
self.preparing = false;
// Play pressed before the version was prepared plays now.
if let Some(tag) = self.play_when_prepared.take() {
if error.is_none() && backend::is_prepared(&tag) {
self.starting_pack = None;
self.start_client(tag);
}
}
if let (None, Some(started)) = (&error, self.prepare_started.take()) {
self.perf.record_since("launcher.prepare_ms", started);
}
@@ -1164,9 +1115,10 @@ impl Launcher {
self.starting_client = Some(tag.clone());
self.play_pressed = Some(Instant::now());
let tx = self.tx.clone();
let offline_without_owner = self.settings.dev_offline;
std::thread::spawn(move || {
backend::complete_assets(&tag);
let session = account::session_for_game(&backend::game_home());
let session = account::session_for_game(&backend::game_home(), offline_without_owner);
let _ = tx.send(Msg::Session(session));
});
}
@@ -1533,6 +1485,27 @@ fn segments<T: Copy + PartialEq + 'static>(
}))
}
/// What the Play button does for the selected version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PlayPress {
Play,
/// Not prepared, for an account that owns the game: prepare it (from the player's install,
/// else downloaded from Mojang for that account), then play, as the official launcher does.
PrepareThenPlay,
/// Not prepared and no owner (signing in comes first), or busy preparing or playing.
Nothing,
}
/// `owner`: whether an account here owns the game, by the keychain (`Launcher::owner`).
fn play_press(prepared: bool, owner: Option<bool>, busy: bool) -> PlayPress {
match (prepared, owner) {
_ if busy => PlayPress::Nothing,
(true, _) => PlayPress::Play,
(false, Some(true)) => PlayPress::PrepareThenPlay,
(false, _) => PlayPress::Nothing,
}
}
/// The selected version once one has been removed: the saved choice if it is still installed,
/// else the newest left.
fn selection_after_removal(installed: &[String], saved: Option<String>) -> Option<String> {
@@ -1567,6 +1540,15 @@ fn page_header(title: &str, description: &str, t: &Theme) -> Div {
/// One row of a list, as its own panel: a name in the pixel face with an optional detail under
/// it, then a value or a control on the right. Rows were numbered in the quiet look; `_n` keeps
/// the call sites as they are.
/// How an available update is named: a Nightly by its title ("Nightly 2026-10-28 · 0c02067"), a
/// release by its tag.
fn update_name(release: &Release) -> &str {
match release.nightly {
Some(_) => &release.name,
None => &release.tag,
}
}
fn index_row(
_n: usize,
name: impl IntoElement,
@@ -2098,9 +2080,14 @@ impl Launcher {
}
foot = foot.child(
caps(format!("Launcher {}", backend::VERSION))
.id("launcher-version")
.px(px(8.))
.pt(px(6.))
.text_color(t.muted),
.text_color(t.muted)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.version_clicked();
cx.notify();
})),
);
div()
.id("sidebar")
@@ -2287,14 +2274,23 @@ impl Launcher {
.top(px(17.))
.child(blocks::icon("play", 22., t.on_face)),
);
if !prepared || self.preparing || running {
play = disabled(play);
} else {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play();
cx.notify();
}));
match play_press(prepared, self.owner, self.preparing || running) {
PlayPress::Nothing => play = disabled(play),
PlayPress::Play => {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play();
cx.notify();
}))
}
PlayPress::PrepareThenPlay => {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play_when_prepared = this.selected().cloned();
this.prepare(backend::MinecraftFrom::Install);
cx.notify();
}))
}
}
let actions = div()
.flex()
@@ -2320,10 +2316,16 @@ impl Launcher {
// Not prepared: an owner can have Horizon download Minecraft from Mojang; without an
// owner there is nothing to play, so signing in comes first.
let first = (!prepared && !self.preparing)
.then_some(self.owner)
.then_some(self.owner_shown())
.flatten();
// No launcher folder with Minecraft on this computer: an owner needs none.
let no_install = self.settings.minecraft_dir.is_none();
let resources_value = match first {
_ if self.preparing => tr("Preparing…").to_string(),
Some(true) if no_install => trf(
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.",
&[("version", &version)],
),
Some(true) => trf(
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.",
&[("version", &version)],
@@ -2353,6 +2355,18 @@ impl Launcher {
}));
}
let prepare = match first {
Some(true) if no_install => div().child(
button(
"prepare-download",
"Download and prepare",
Kind::Secondary,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.prepare(backend::MinecraftFrom::Mojang);
cx.notify();
})),
),
Some(true) => div()
.flex()
.gap(px(8.))
@@ -2386,12 +2400,15 @@ impl Launcher {
),
None => div().child(prepare),
};
let owner = self.owner == Some(true);
let minecraft = self.settings.minecraft_dir.as_deref().map_or_else(
|| {
div()
.child(tr(
"Not found. Choose the folder where your launcher keeps Minecraft.",
))
.child(if owner {
tr("None needed: Horizon downloads Minecraft from Mojang for your account.")
} else {
tr("Not found. Choose the folder where your launcher keeps Minecraft.")
})
.into_any_element()
},
|p| {
@@ -2430,7 +2447,7 @@ impl Launcher {
rows = rows.child(self.join_friend_row(prepared && !running, cx));
// The top bar shows who plays; a row only when there is something to do about it:
// no account that owns the game (Play needs one), or offline.
if self.owner == Some(false) {
if self.owner_shown() == Some(false) {
rows = rows.child(index_row(
5,
tr("Microsoft account"),
@@ -2782,7 +2799,7 @@ impl Launcher {
.text_size(px(theme::CAPTION.px))
.text_color(t.muted)
.child(div().size(px(6.)).rounded_full().bg(t.accent))
.child(format!("{} available", release.tag));
.child(format!("{} available", update_name(release)));
hero = hero.child(match installing {
Some((_, p)) => line
.child(
@@ -2883,7 +2900,7 @@ impl Launcher {
}),
);
let horizon = match (&self.updates.horizon, &self.update_error) {
(Some(r), _) => trf("{version} available", &[("version", &r.tag)]),
(Some(r), _) => trf("{version} available", &[("version", update_name(r))]),
(None, Some(e)) => e.clone(),
(None, None) if self.checked.is_some() => tr("Up to date").to_string(),
_ => tr("Checking…").to_string(),
@@ -3011,7 +3028,6 @@ impl Launcher {
Some(toggle.into_any_element()),
&t,
))
.child(self.nightly_row(n + 2, cx))
.child(group("Launcher", "monitor"))
.child(index_row(
n + 3,
@@ -3089,57 +3105,7 @@ impl Launcher {
)
.child(self.collection_section(cx))
.children(self.agents_section(cx))
}
/// Settings' Nightly row: off by default; on, the nightly build installs and updates in its
/// own instance, and the release stays one click away (the Horizon instance).
fn nightly_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let on = self.settings.nightly;
let toggle =
switch("nightly", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.set_nightly(!this.settings.nightly);
cx.notify();
}));
let what = tr(
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.",
);
let status = match (&self.nightly_progress, &self.nightly_error) {
_ if !on => tr("Plays in its own Nightly instance: your worlds and the release stay as they are.")
.to_string(),
(Some(p), _) => trf(
"Installing the nightly, {percent}%",
&[("percent", &format!("{:.0}", p * 100.))],
),
(None, Some(e)) => trf("The nightly could not be installed: {error}", &[("error", e)]),
(None, None) => match self
.instances
.list
.iter()
.find(|i| i.id == nightly::INSTANCE)
.and_then(|i| i.version.clone())
{
Some(version) => trf(
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.",
&[("version", &version)],
),
None => tr("No nightly build yet.").to_string(),
},
};
index_row(
n,
tr("Nightly builds"),
Some(
div()
.flex()
.flex_col()
.child(what.to_string())
.child(status)
.into_any_element(),
),
Some(toggle.into_any_element()),
&t,
)
.children(self.dev_options_section(cx))
}
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
@@ -4413,6 +4379,24 @@ mod tests {
);
}
/// An owner's Play works on a version not prepared yet: it prepares it (downloading
/// Minecraft from Mojang when this computer has no install), then plays. Without an owner
/// it stays off: signing in comes first.
#[test]
fn play_prepares_first_for_an_owner() {
use super::{play_press, PlayPress};
assert_eq!(play_press(true, Some(true), false), PlayPress::Play);
assert_eq!(play_press(true, None, false), PlayPress::Play);
assert_eq!(
play_press(false, Some(true), false),
PlayPress::PrepareThenPlay
);
assert_eq!(play_press(false, Some(false), false), PlayPress::Nothing);
assert_eq!(play_press(false, None, false), PlayPress::Nothing);
assert_eq!(play_press(false, Some(true), true), PlayPress::Nothing);
assert_eq!(play_press(true, Some(true), true), PlayPress::Nothing);
}
#[test]
fn the_app_menu_has_quit() {
let menus = super::menus();
+173
View File
@@ -0,0 +1,173 @@
//! The versions of Minecraft: Java Edition a Java instance can play: Mojang's public version list
//! (`version_manifest_v2.json`, the one the official launcher reads), kept in the launcher's data
//! folder so the list still shows offline. It names each version and where its JSON is; the game's
//! files themselves are downloaded only for an account that owns the game (`mojang.rs`).
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
const MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
/// What kind of version it is, as Mojang lists it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Release,
Snapshot,
OldBeta,
OldAlpha,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Version {
pub id: String,
pub kind: Kind,
/// Its version JSON, and that file's sha1.
pub url: String,
pub sha1: String,
/// When it came out (ISO 8601).
pub released: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Versions {
pub latest_release: String,
pub latest_snapshot: String,
/// The newest first, as Mojang lists them.
pub all: Vec<Version>,
}
impl Versions {
pub fn parse(text: &str) -> Result<Versions, String> {
let json: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("Mojang's version list: {e}"))?;
let all = json["versions"]
.as_array()
.ok_or("Mojang's version list has no versions")?
.iter()
.filter_map(|v| {
let kind = match v["type"].as_str()? {
"release" => Kind::Release,
"snapshot" => Kind::Snapshot,
"old_beta" => Kind::OldBeta,
"old_alpha" => Kind::OldAlpha,
_ => return None,
};
Some(Version {
id: v["id"].as_str()?.to_string(),
kind,
url: v["url"].as_str()?.to_string(),
sha1: v["sha1"].as_str()?.to_string(),
released: v["releaseTime"].as_str().unwrap_or("").to_string(),
})
})
.collect();
let latest = |key: &str| json["latest"][key].as_str().unwrap_or("").to_string();
Ok(Versions {
latest_release: latest("release"),
latest_snapshot: latest("snapshot"),
all,
})
}
/// The version `name` names: an id (`1.21.4`), `latest` (the newest release) or
/// `latest-snapshot`.
pub fn find(&self, name: &str) -> Result<&Version, String> {
let id = match name.trim() {
"latest" | "release" => self.latest_release.as_str(),
"latest-snapshot" | "snapshot" => self.latest_snapshot.as_str(),
id => id,
};
self.all.iter().find(|v| v.id == id).ok_or_else(|| {
format!("Minecraft has no version {name:?}. `horizon-launcher instance versions` lists them.")
})
}
/// The versions of the kinds asked for, the newest first.
pub fn of(&self, snapshots: bool, old: bool) -> impl Iterator<Item = &Version> {
self.all.iter().filter(move |v| match v.kind {
Kind::Release => true,
Kind::Snapshot => snapshots,
Kind::OldBeta | Kind::OldAlpha => old,
})
}
}
fn cache() -> PathBuf {
crate::backend::data_dir()
.join("minecraft")
.join("version_manifest_v2.json")
}
/// Mojang's list, fetched now and kept; the kept one when Mojang can't be reached.
pub fn load() -> Result<Versions, String> {
load_with(&cache(), &|url| {
let mut body = String::new();
std::io::Read::read_to_string(&mut crate::backend::get(url)?.into_reader(), &mut body)
.map_err(|e| e.to_string())?;
Ok(body)
})
}
fn load_with(
cache: &Path,
fetch: &dyn Fn(&str) -> Result<String, String>,
) -> Result<Versions, String> {
match fetch(MANIFEST).and_then(|text| Versions::parse(&text).map(|v| (v, text))) {
Ok((versions, text)) => {
let _ =
fs::create_dir_all(cache.parent().unwrap()).and_then(|_| fs::write(cache, text));
Ok(versions)
}
Err(e) => fs::read_to_string(cache)
.map_err(|_| format!("Mojang's version list could not be fetched: {e}"))
.and_then(|text| Versions::parse(&text)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const LIST: &str = r#"{
"latest": {"release": "1.21.4", "snapshot": "25w02a"},
"versions": [
{"id": "25w02a", "type": "snapshot", "url": "https://piston-meta.mojang.com/v1/packages/a/25w02a.json", "sha1": "a", "releaseTime": "2025-01-08T12:00:00+00:00"},
{"id": "1.21.4", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/b/1.21.4.json", "sha1": "b", "releaseTime": "2024-12-03T10:12:57+00:00"},
{"id": "b1.7.3", "type": "old_beta", "url": "https://piston-meta.mojang.com/v1/packages/c/b1.7.3.json", "sha1": "c", "releaseTime": "2011-07-07T22:00:00+00:00"},
{"id": "x", "type": "experiment", "url": "u", "sha1": "d"}
]
}"#;
#[test]
fn the_list_names_each_version_and_the_latest_ones() {
let v = Versions::parse(LIST).unwrap();
assert_eq!(v.all.len(), 3, "unknown kinds are left out");
assert_eq!(v.find("latest").unwrap().id, "1.21.4");
assert_eq!(v.find("latest-snapshot").unwrap().kind, Kind::Snapshot);
assert_eq!(v.find("b1.7.3").unwrap().sha1, "c");
assert!(v.find("1.99").unwrap_err().contains("no version \"1.99\""));
let ids = |s, o| v.of(s, o).map(|v| v.id.as_str()).collect::<Vec<_>>();
assert_eq!(ids(false, false), ["1.21.4"]);
assert_eq!(ids(true, true), ["25w02a", "1.21.4", "b1.7.3"]);
}
#[test]
fn the_kept_list_shows_offline() {
let dir = std::env::temp_dir().join(format!("horizon-mc-versions-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let cache = dir.join("minecraft/version_manifest_v2.json");
let offline = |_: &str| -> Result<String, String> { Err("no network".into()) };
assert!(load_with(&cache, &offline)
.unwrap_err()
.contains("could not be fetched: no network"));
let online = |url: &str| -> Result<String, String> {
assert_eq!(url, MANIFEST);
Ok(LIST.into())
};
assert_eq!(load_with(&cache, &online).unwrap().latest_release, "1.21.4");
assert_eq!(load_with(&cache, &offline).unwrap().all.len(), 3);
fs::remove_dir_all(&dir).unwrap();
}
}
+50 -13
View File
@@ -60,9 +60,15 @@ pub fn download(
fetch_manifest(pins, &manifest, fetch, log)?;
let manifest = read_json(&manifest)?;
let client = &manifest["downloads"]["client"];
// A version Horizon doesn't pin takes its client's sha1 from its version file, itself
// checked against the sha1 Mojang's version list gives (`Pins::for_version`).
let client_sha1 = match pins.client_sha1.as_str() {
"" => text(client, "sha1")?,
pinned => pinned.to_string(),
};
let mut game = vec![File {
url: text(client, "url")?,
sha1: pins.client_sha1.clone(),
sha1: client_sha1,
size: client["size"].as_u64(),
path: versions.join(format!("{v}.jar")),
}];
@@ -70,22 +76,40 @@ pub fn download(
.as_array()
.ok_or("The version file has no libraries")?;
for library in libraries.iter().filter(|l| allowed(l, os)) {
let artifact = &library["downloads"]["artifact"];
if artifact.is_null() {
continue;
// Before 1.19 a library's natives for this system are a classifier of its own.
let native = native_classifier(library, os)
.map(|key| &library["downloads"]["classifiers"][key.as_str()]);
for artifact in [Some(&library["downloads"]["artifact"]), native]
.into_iter()
.flatten()
.filter(|a| !a.is_null())
{
game.push(File {
url: text(artifact, "url")?,
sha1: text(artifact, "sha1")?,
size: artifact["size"].as_u64(),
path: dir
.join("libraries")
.join(relative(&text(artifact, "path")?)?),
});
}
}
let libraries = game.len() - 1;
// The logging setup the game is started with (`logging.client.argument`): for 1.7 to 1.18,
// Mojang's fixed one is what keeps Log4Shell out.
let logging = &manifest["logging"]["client"]["file"];
if !logging.is_null() {
game.push(File {
url: text(artifact, "url")?,
sha1: text(artifact, "sha1")?,
size: artifact["size"].as_u64(),
url: text(logging, "url")?,
sha1: text(logging, "sha1")?,
size: logging["size"].as_u64(),
path: dir
.join("libraries")
.join(relative(&text(artifact, "path")?)?),
.join("assets/log_configs")
.join(relative(&text(logging, "id")?)?),
});
}
log(format!(
"Downloading Minecraft {v} from Mojang: the game and {} libraries",
game.len() - 1
"Downloading Minecraft {v} from Mojang: the game and {libraries} libraries"
));
fetch_all(&game, jobs, fetch, cancel, log)?;
let index = &manifest["assetIndex"];
@@ -112,7 +136,7 @@ pub fn download(
}
/// Mojang's name for this system in its Java runtime index.
fn java_platform() -> Option<&'static str> {
pub(crate) fn java_platform() -> Option<&'static str> {
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "mac-os-arm64",
("macos", "x86_64") => "mac-os",
@@ -244,7 +268,7 @@ fn text(v: &Value, key: &str) -> Result<String, String> {
}
/// A path from the version file, refused if it could leave the folder.
fn relative(path: &str) -> Result<PathBuf, String> {
pub(crate) fn relative(path: &str) -> Result<PathBuf, String> {
let safe = !path.contains('\\')
&& Path::new(path)
.components()
@@ -265,6 +289,19 @@ fn allowed(library: &Value, os: &str) -> bool {
.is_some_and(|rule| rule["action"] == "allow")
}
/// The classifier holding a library's natives for `os` (`natives-osx`), from its `natives` map;
/// `${arch}` is this system's word size, as old versions name Windows' natives.
pub(crate) fn native_classifier(library: &Value, os: &str) -> Option<String> {
let arch = if cfg!(target_pointer_width = "64") {
"64"
} else {
"32"
};
library["natives"][os]
.as_str()
.map(|key| key.replace("${arch}", arch))
}
/// The asset index's objects, once each.
fn objects(index: &Value, dir: &Path) -> Result<Vec<File>, String> {
let objects = index["objects"]
+111
View File
@@ -8,6 +8,7 @@ const COMMON: &[u8] = b"a library every system uses";
const LINUX: &[u8] = b"a linux native";
const SOUND: &[u8] = b"a fake ogg";
const LANG: &[u8] = b"{}";
const LOGGING: &[u8] = b"<Configuration/>";
/// Never cancelled.
static GO: AtomicBool = AtomicBool::new(false);
@@ -456,3 +457,113 @@ fn a_cancelled_download_goes_on_where_it_stopped() {
assert_eq!(asked.len(), 6, "{asked:#?}");
fs::remove_dir_all(&dir).unwrap();
}
/// A version Horizon doesn't pin, as Mojang's list names it (its version file's sha1): the
/// client's sha1 comes from that checked file, and an old version's natives for this system
/// (a classifier of their library, `${arch}` filled in) come with the libraries.
#[test]
fn any_listed_version_comes_with_its_natives() {
let native = |name: &str, data: &[u8]| {
serde_json::json!({
"path": format!("org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"),
"sha1": sha1_hex(data),
"url": format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar"),
})
};
let index = serde_json::json!({"objects": {}}).to_string();
let manifest = serde_json::json!({
"id": "1.12.2",
"assetIndex": {"id": "1.12", "sha1": sha1_hex(index.as_bytes()),
"url": "https://piston-meta.mojang.com/v1/packages/i/1.12.json"},
"downloads": {"client": {"sha1": sha1_hex(CLIENT), "size": CLIENT.len(), "url": "https://piston-data.mojang.com/old.jar"}},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {
"id": "client-1.12.xml", "sha1": sha1_hex(LOGGING), "size": LOGGING.len(),
"url": "https://piston-data.mojang.com/client-1.12.xml"}}},
"libraries": [{
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
"natives": {"osx": "natives-osx", "windows": "natives-windows-${arch}"},
"downloads": {"classifiers": {
"natives-osx": native("natives-osx", b"osx natives"),
"natives-windows-64": native("natives-windows-64", b"win64 natives"),
"natives-windows-32": native("natives-windows-32", b"win32 natives"),
}},
}],
})
.to_string();
let sha1 = sha1_hex(manifest.as_bytes());
let lib = |name: &str| format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar");
let server = Server {
files: HashMap::from([
(
format!("https://piston-meta.mojang.com/v1/packages/{sha1}/1.12.2.json"),
manifest.into_bytes(),
),
(
"https://piston-data.mojang.com/old.jar".into(),
CLIENT.to_vec(),
),
(
"https://piston-data.mojang.com/client-1.12.xml".into(),
LOGGING.to_vec(),
),
(lib("natives-osx"), b"osx natives".to_vec()),
(lib("natives-windows-64"), b"win64 natives".to_vec()),
(lib("natives-windows-32"), b"win32 natives".to_vec()),
(
"https://piston-meta.mojang.com/v1/packages/i/1.12.json".into(),
index.into_bytes(),
),
]),
asked: Mutex::new(Vec::new()),
};
let pins = Pins::for_version("1.12.2", &sha1);
let platform = |os: &str| {
let dir = scratch(&format!("any-{os}"));
download(&pins, &dir, os, 2, &|url| server.fetch(url), &GO, &|_| {}).unwrap();
dir
};
let mac = platform("osx");
assert_eq!(
fs::read(mac.join("versions/1.12.2/1.12.2.jar")).unwrap(),
CLIENT
);
let natives = |dir: &Path, name: &str| {
dir.join(format!(
"libraries/org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"
))
};
assert!(natives(&mac, "natives-osx").is_file());
assert_eq!(
fs::read(mac.join("assets/log_configs/client-1.12.xml")).unwrap(),
LOGGING
);
assert!(!natives(&mac, "natives-windows-64").exists());
let windows = platform("windows");
assert!(natives(&windows, "natives-windows-64").is_file());
assert!(!natives(&windows, "natives-windows-32").exists());
// Linux has no natives in this library: nothing to fetch for it, and no error.
let linux = platform("linux");
assert!(!linux.join("libraries").exists());
assert!(linux.join("assets/log_configs/client-1.12.xml").is_file());
for dir in [mac, windows, linux] {
fs::remove_dir_all(dir).unwrap();
}
// A client that isn't the one its checked version file names is refused.
let wrong = Server {
files: server
.files
.clone()
.into_iter()
.map(|(url, body)| match url.ends_with("old.jar") {
true => (url, b"another jar".to_vec()),
false => (url, body),
})
.collect(),
asked: Mutex::new(Vec::new()),
};
let dir = scratch("any-wrong");
let refused = download(&pins, &dir, "osx", 1, &|url| wrong.fetch(url), &GO, &|_| {});
assert!(refused.unwrap_err().contains("old.jar"));
let _ = fs::remove_dir_all(&dir);
}
+347 -19
View File
@@ -5,7 +5,10 @@
//! player's rights.
//!
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
//! every mod of one build must pin the same one. The build compiles that revision's source, from
//! every mod of one build must pin the same one. A mod that hooks only Horizon's native hook points
//! (`game/native_hooks.rs`) names the version it needs instead (`hooks`): it builds on any Horizon
//! whose hook points have that version or a later one (they are only ever added to), the player's own
//! by default, so such mods always build together. The build compiles that revision's source, from
//! GitHub, never a checkout on this computer, and installs as its own version,
//! `native-<revision>-<key>` (the key hashes the revision, the mods' files, the tool's commit and
//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a
@@ -39,6 +42,16 @@ fn root() -> PathBuf {
backend::data_dir().join("native")
}
/// A canonical path without Windows' verbatim prefix: `canonicalize` gives `\\?\C:\...` there,
/// and horizon-mixins' output check stats `\\?\C:`, the volume itself, which Windows refuses
/// (os error 1). A drive path loses its `\\?\`; any other path is left as it is.
fn plain(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|p| p.strip_prefix(r"\\?\")) {
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
_ => path,
}
}
/// The build's log, kept until the next build.
pub fn log_path() -> PathBuf {
root().join("build.log")
@@ -96,6 +109,9 @@ pub struct Bundle {
pub horizon: Option<String>,
/// The horizon-mixins commit it was tested with (its `.hnmod`'s).
pub mixins: Option<String>,
/// The native hook points' version it needs (its `.hnmod`'s `hooks`): then `horizon` is the
/// revision it was tested on, and it builds on any with these hook points or later ones.
pub hooks: Option<u32>,
}
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
@@ -133,9 +149,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let native: serde_json::Value =
let native_json: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let field = |name: &str| native[name].as_str().map(str::to_owned);
let field = |name: &str| native_json[name].as_str().map(str::to_owned);
let id = field("id").ok_or_else(|| format!("{} has no id.", path.display()))?;
let found = bundle(&dir.join("mixins").join(&id), field("mixins"))?;
match (&found.horizon, field("horizon")) {
@@ -144,6 +160,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
)),
(_, native) => Ok(Some(Bundle {
horizon: found.horizon.clone().or(native),
hooks: native_json["hooks"]
.as_u64()
.and_then(|v| u32::try_from(v).ok()),
..found
})),
}
@@ -171,6 +190,7 @@ fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
dir: dir.to_owned(),
horizon: field("compatibility", "horizon_revision"),
mixins,
hooks: None,
})
}
@@ -213,6 +233,90 @@ fn agreed(
Ok(revision)
}
/// Where Horizon declares its native hook points' version, and the line that does.
const HOOKS_FILE: &str = "crates/horizon-client/src/game/native_hooks.rs";
const HOOKS_LINE: &str = "pub(crate) const NATIVE_HOOKS_API: u32 = ";
/// The native hook points' version in `HOOKS_FILE`'s text.
fn hooks_in(source: &str) -> Option<u32> {
let line = source.lines().find_map(|l| l.strip_prefix(HOOKS_LINE))?;
line.trim_end().strip_suffix(';')?.parse().ok()
}
/// The native hook points' version of Horizon at `revision`, from GitHub (None: it has none).
fn hooks_at(revision: &str) -> Option<u32> {
let url = format!(
"https://raw.githubusercontent.com/{}/{revision}/{HOOKS_FILE}",
backend::REPO
);
let text = backend::get(&url).ok()?.into_string().ok()?;
hooks_in(&text)
}
/// The revision to build `mods` on: `pinned` (the pins' agreement, else the installed version's)
/// when every mod naming a hook points' version finds it or a later one there (`at` reads a
/// revision's). Else, when nothing pins it, the revision those mods were tested on.
fn with_hooks(
mods: &[Bundle],
pinned: String,
free: bool,
at: impl Fn(&str) -> Option<u32>,
) -> Result<String, String> {
let hooked: Vec<&Bundle> = mods.iter().filter(|b| b.hooks.is_some()).collect();
let fits = |revision: &str| {
let found = at(revision);
hooked.iter().all(|b| found.is_some() && b.hooks <= found)
};
if hooked.is_empty() || fits(&pinned) {
return Ok(pinned);
}
let tested: Vec<Bundle> = hooked.iter().map(|b| (*b).clone()).collect();
let fallback = agreed(
&tested,
"Horizon",
|b| b.horizon.as_ref(),
|| Err(String::new()),
);
if let (true, Ok(revision)) = (free, fallback) {
if fits(&revision) {
return Ok(revision);
}
}
let list: Vec<String> = hooked
.iter()
.map(|b| format!("{} (hook points v{})", b.id, b.hooks.unwrap_or_default()))
.collect();
Err(format!(
"Horizon {} doesn't have the native hook points these mods use: {}. Update Horizon or the mods.",
&pinned[..pinned.len().min(7)],
list.join(", ")
))
}
/// A bundle that names a hook points' version builds on another revision than the one it was
/// tested on: its `horizon_revision` (which horizon-mixins checks) becomes the build's.
fn repin(bundle: &Bundle, revision: &str) -> Result<(), String> {
let path = bundle.dir.join(MANIFEST);
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut value: toml::Table = text
.parse()
.map_err(|e| format!("{}: {e}", path.display()))?;
if let Some(toml::Value::Table(compat)) = value.get_mut("compatibility") {
compat.insert("horizon_revision".into(), revision.into());
}
let text = toml::to_string(&value).map_err(|e| e.to_string())?;
fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))
}
/// Whether Horizon's source at `source` has the `mods` feature (the WebAssembly mods' runtime,
/// gone from later revisions).
fn has_mods_feature(source: &Path) -> bool {
let path = source.join("crates/horizon-client/Cargo.toml");
let text = fs::read_to_string(path).unwrap_or_default();
let value: toml::Table = text.parse().unwrap_or_default();
value.get("features").and_then(|f| f.get("mods")).is_some()
}
/// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub.
pub fn revision_of(version: &str) -> Result<String, String> {
let reference = match version.strip_prefix("nightly-") {
@@ -388,6 +492,16 @@ fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
Ok(bin)
}
/// `horizon-mixins apply`: the mods in `mods` applied to `source`, into `out`. It runs
/// `cargo metadata`, so it gets the launcher's toolchain like a build does: on a computer with no
/// Rust of its own, a plain command found no cargo (E_METADATA).
fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) -> Command {
let mut cmd = rust.command(tool);
cmd.arg("apply").arg("--workspace").arg(source);
cmd.arg("--mods").arg(mods).arg("--out").arg(out);
cmd
}
/// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in
/// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly
/// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out:
@@ -478,12 +592,19 @@ pub fn build(
..b
})
.collect();
let revision = agreed(
&mods,
// A mod naming a hook points' version doesn't pin: it follows the others, else the game.
let pins: Vec<Bundle> = mods.iter().filter(|b| b.hooks.is_none()).cloned().collect();
let free = pins.iter().all(|b| b.horizon.is_none());
let pinned = agreed(
&pins,
"Horizon",
|b| b.horizon.as_ref(),
|| revision_of(base),
)?;
let revision = with_hooks(&mods, pinned, free, hooks_at)?;
for b in mods.iter().filter(|b| b.hooks.is_some()) {
repin(b, &revision)?;
}
let mixins = agreed(
&mods,
"horizon-mixins",
@@ -500,27 +621,24 @@ pub fn build(
let source = source(&revision)?;
progress(Step::Mixins);
// horizon-mixins refuses an output path through a link (macOS's /tmp is one).
let real = root().canonicalize().map_err(|e| e.to_string())?;
let real = plain(root().canonicalize().map_err(|e| e.to_string())?);
let fresh = real.join("stage.new");
let _ = fs::remove_dir_all(&fresh);
let mut apply = Command::new(&tool);
apply.arg("apply").arg("--workspace").arg(&source);
apply.arg("--mods").arg(&incoming).arg("--out").arg(&fresh);
run(apply, "horizon-mixins", |_| {})?;
run(
apply(&rust, &tool, &source, &incoming, &fresh),
"horizon-mixins",
|_| {},
)?;
let stage = root().join("stage");
sync(&fresh, &stage)?;
let _ = fs::remove_dir_all(&fresh);
let target = root().join("target");
let mut compile = rust.command(&rust.cargo());
compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target);
compile.args([
"build",
"--locked",
"--profile",
PROFILE,
"--features",
FEATURES,
]);
compile.args(["build", "--locked", "--profile", PROFILE]);
if has_mods_feature(&stage) {
compile.args(["--features", FEATURES]);
}
compile.args(["-p", "horizon-client", "--bin", "horizon-client"]);
let mut crates = 0;
run(compile, "Compiling the game", |line| {
@@ -535,6 +653,7 @@ pub fn build(
"base": base,
"horizon": revision,
"horizon_mixins": mixins,
"hooks": mods.iter().find_map(|m| m.hooks),
"rust": rust_toolchain::VERSION,
"mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::<Vec<_>>(),
});
@@ -543,7 +662,12 @@ pub fn build(
}
match instance.filter(|i| i.kind != instances::Kind::Default) {
Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?,
None => instance_on(&version)?,
None => {
let native = instance_on(&version)?;
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
carry_settings(&backend::game_home(), &native.home, &ids)?;
native
}
};
prune(&version);
let _ = fs::remove_dir_all(&incoming);
@@ -600,6 +724,42 @@ fn instance_on(version: &str) -> Result<Instance, String> {
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
}
/// The mods' settings from the game's own home `from` (where their WebAssembly versions kept
/// them) into the Native mods instance's `to`, for each mod `to` has none of yet: its storage
/// folder (`horizon-mods/.horizon-mod-data/<id>/`) and its keys (`key.<id>.<name>:…` lines of
/// `horizon-mods.txt`). Nothing is overwritten.
fn carry_settings(from: &Path, to: &Path, ids: &[&str]) -> Result<(), String> {
const DATA: &str = "horizon-mods/.horizon-mod-data";
const LIST: &str = "horizon-mods.txt";
let read = |home: &Path| fs::read_to_string(home.join(LIST)).unwrap_or_default();
let (theirs, mut ours) = (read(from), read(to));
let before = ours.len();
for id in ids {
let data = from.join(DATA).join(id);
let target = to.join(DATA).join(id);
if data.is_dir() && !target.exists() {
backend::copy_dir(&data, &target).map_err(|e| format!("{}: {e}", data.display()))?;
}
let prefix = format!("key.{id}.");
let has_keys = ours.lines().any(|l| l.starts_with(&prefix));
for line in theirs
.lines()
.filter(|l| l.starts_with(&prefix) && !has_keys)
{
if !ours.is_empty() && !ours.ends_with('\n') {
ours.push('\n');
}
ours += line;
ours.push('\n');
}
}
if ours.len() != before {
fs::create_dir_all(to).map_err(|e| e.to_string())?;
fs::write(to.join(LIST), ours).map_err(|e| format!("{LIST}: {e}"))?;
}
Ok(())
}
/// Removes the native builds other than `keep` that no instance plays. The compiled crates
/// (`native/target`) stay, so the next build only compiles what changed.
pub fn prune(keep: &str) {
@@ -625,6 +785,46 @@ mod tests {
d
}
#[test]
fn the_native_mods_instance_gets_the_mods_settings_once() {
let (from, to) = (dir("carry-from"), dir("carry-to"));
let data = from.join("horizon-mods/.horizon-mod-data");
fs::create_dir_all(data.join("zoom")).unwrap();
fs::write(data.join("zoom/level"), "8").unwrap();
fs::create_dir_all(data.join("other")).unwrap();
fs::write(
from.join("horizon-mods.txt"),
"mod.zoom:true\nkey.zoom.zoom:key.keyboard.c\nkey.other.x:key.keyboard.x\n",
)
.unwrap();
fs::write(
to.join("horizon-mods.txt"),
"key.minimap.map:key.keyboard.m",
)
.unwrap();
carry_settings(&from, &to, &["zoom", "minimap"]).unwrap();
let ours = to.join("horizon-mods/.horizon-mod-data");
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "8");
assert!(!ours.join("other").exists());
assert_eq!(
fs::read_to_string(to.join("horizon-mods.txt")).unwrap(),
"key.minimap.map:key.keyboard.m\nkey.zoom.zoom:key.keyboard.c\n"
);
// Changed in the instance since: kept.
fs::write(ours.join("zoom/level"), "4").unwrap();
fs::write(
from.join("horizon-mods.txt"),
"key.zoom.zoom:key.keyboard.v\n",
)
.unwrap();
carry_settings(&from, &to, &["zoom"]).unwrap();
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "4");
assert!(fs::read_to_string(to.join("horizon-mods.txt"))
.unwrap()
.contains("key.zoom.zoom:key.keyboard.c"));
let _ = (fs::remove_dir_all(&from), fs::remove_dir_all(&to));
}
fn write_bundle(at: &Path, id: &str, helper: &str) {
fs::create_dir_all(at).unwrap();
let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n");
@@ -632,6 +832,59 @@ mod tests {
fs::write(at.join("hook.rs"), helper).unwrap();
}
#[test]
fn a_canonical_windows_path_loses_only_a_drives_verbatim_prefix() {
let plain = |p: &str| plain(PathBuf::from(p));
assert_eq!(
plain(r"\\?\C:\Users\me\AppData\Roaming\horizon-launcher\native"),
PathBuf::from(r"C:\Users\me\AppData\Roaming\horizon-launcher\native")
);
// A share has no drive form to fall back to; other paths have no prefix.
assert_eq!(
plain(r"\\?\UNC\server\share\native"),
PathBuf::from(r"\\?\UNC\server\share\native")
);
assert_eq!(
plain("/home/me/.local/share/horizon-launcher/native"),
PathBuf::from("/home/me/.local/share/horizon-launcher/native")
);
}
#[test]
fn horizon_mixins_finds_the_launchers_cargo() {
let rust = Toolchain {
dir: PathBuf::from("/rust/1.99.1-x"),
};
let cmd = apply(
&rust,
Path::new("/tools/horizon-mixins"),
Path::new("/src"),
Path::new("/mods"),
Path::new("/out"),
);
let path = cmd
.get_envs()
.find(|(k, _)| *k == "PATH")
.and_then(|(_, v)| v)
.unwrap();
assert_eq!(
std::env::split_paths(path).next(),
Some(rust.dir.join("bin"))
);
assert_eq!(
cmd.get_args().collect::<Vec<_>>(),
[
"apply",
"--workspace",
"/src",
"--mods",
"/mods",
"--out",
"/out"
]
);
}
#[test]
fn a_folder_is_one_bundle_or_a_folder_of_them() {
let one = dir("one");
@@ -721,6 +974,7 @@ mod tests {
dir: PathBuf::new(),
horizon: horizon.map(str::to_owned),
mixins: None,
hooks: None,
};
let never = || -> Result<String, String> { panic!("a pin wins") };
fn get(b: &Bundle) -> Option<&String> {
@@ -744,6 +998,80 @@ mod tests {
assert!(agreed(&bad, "Horizon", get, never).is_err());
}
#[test]
fn mods_on_the_hook_points_follow_the_game_or_the_pins() {
assert_eq!(
hooks_in("x\npub(crate) const NATIVE_HOOKS_API: u32 = 3;\n"),
Some(3)
);
assert_eq!(hooks_in("const NATIVE_HOOKS_API: u32 = 3;"), None);
let mod_ = |id: &str, horizon: &str, hooks: Option<u32>| Bundle {
id: id.into(),
version: "1".into(),
dir: PathBuf::new(),
horizon: Some(horizon.into()),
mixins: None,
hooks,
};
let c = "c".repeat(40);
// A (the game) has hook points v1, B v2, C none.
let at = |r: &str| match r.as_bytes()[0] {
b'a' => Some(1),
b'b' => Some(2),
_ => None,
};
let zoom = [mod_("zoom", B, Some(1)), mod_("minimap", &c, Some(1))];
assert_eq!(
with_hooks(&zoom, A.into(), true, at).unwrap(),
A,
"the game's own"
);
let tested = [mod_("zoom", A, Some(1))];
assert_eq!(
with_hooks(&tested, c.clone(), true, at).unwrap(),
A,
"an older game"
);
assert_eq!(
with_hooks(&tested, B.into(), false, at).unwrap(),
B,
"v1 mods on v2"
);
let newer = [mod_("recipes", B, Some(2))];
let err = with_hooks(&newer, A.into(), false, at).unwrap_err();
assert!(
err.contains("aaaaaaa") && err.contains("recipes (hook points v2)"),
"{err}"
);
assert_eq!(
with_hooks(&newer, A.into(), true, at).unwrap(),
B,
"its tested commit"
);
let pinned_only = [mod_("veinminer", B, None)];
assert_eq!(with_hooks(&pinned_only, B.into(), false, at).unwrap(), B);
}
#[test]
fn a_hooks_bundle_is_read_and_repinned_to_the_build() {
let d = dir("hooks");
write_native(&d, "zoom", A, B);
let json = fs::read_to_string(d.join(NATIVE_MANIFEST)).unwrap();
let json = json.replacen('}', ",\"hooks\":1}", 1);
fs::write(d.join(NATIVE_MANIFEST), json).unwrap();
let found = bundles(&d).unwrap();
assert_eq!(
(found[0].hooks, found[0].horizon.as_deref()),
(Some(1), Some(A))
);
repin(&found[0], B).unwrap();
let again = bundle(&found[0].dir, None).unwrap();
assert_eq!(
(again.id.as_str(), again.horizon.as_deref()),
("zoom", Some(B))
);
}
#[test]
fn the_version_changes_with_the_revision_and_any_file_of_the_mods() {
let d = dir("key");
+71 -90
View File
@@ -1,19 +1,17 @@
//! Nightly (docs/release-flow.md "Nightly channel"): an opt-in build of main plus pull requests
//! picked by trusted reviewers, some not reviewed yet, published as the `nightly` pre-release each
//! night and when a reviewer asks. Off by default (`Settings::nightly`).
//! Nightly (docs/release-flow.md): the only build Horizon ships since 2026-10-28, main plus the pull
//! requests the trusted reviewers picked, published as the `nightly` pre-release a few times a day.
//! Every launcher installs it as its update (`backend::horizon_update`), as its own version
//! `nightly-<date>-<commit>`, beside the player's worlds, which it never touches. Its checksums must
//! be signed for the nightly channel (`signed::nightly_digest`).
//!
//! It never touches the release: it installs as its own version, `nightly-<date>-<commit>`, and plays
//! in its own instance, "Nightly" (`instances/nightly/` in the game's home), so the player's worlds
//! stay where they are and choosing the Horizon instance plays the release again. Its checksums
//! must be signed for the nightly channel (`signed::nightly_digest`); the release channel refuses
//! them.
use crate::backend::{self, Release, ASSET};
use crate::instances::{self, Instance};
//! Before that it was opt-in and played in its own "Nightly" instance (`instances/nightly/`):
//! that instance, and its worlds, stay, and keep following the Nightly.
use crate::backend::{self, Release};
use crate::instances;
use horizon_release_sign::NIGHTLY;
/// The Nightly instance's id (its folder in `instances/`) and name.
/// The id of the instance (its folder in `instances/`) the opt-in Nightly played in.
pub const INSTANCE: &str = "nightly";
const NAME: &str = "Nightly";
/// The version a nightly installs as: `nightly-2026-10-28-0c02067`, from its title
/// ("Nightly 2026-10-28 · 0c02067"), else its publication date.
@@ -33,6 +31,12 @@ pub fn version_of(release: &Release) -> String {
}
}
/// The date of a nightly's version (`nightly-2026-10-28-0c02067`), or None for any other version.
pub fn date_of(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)| {
@@ -44,112 +48,89 @@ fn is_date(text: &str) -> bool {
})
}
/// The current nightly build, or None while there is none.
pub fn latest() -> Result<Option<Release>, String> {
let url = format!(
"https://api.github.com/repos/{}/releases/tags/{NIGHTLY}",
backend::REPO
);
let release: serde_json::Value = match backend::get(&url) {
Ok(response) => response.into_json().map_err(|e| e.to_string())?,
Err(e) if e.ends_with("answered 404") => return Ok(None),
Err(e) => return Err(e),
};
Ok(parse(&release))
}
fn parse(release: &serde_json::Value) -> Option<Release> {
let list = serde_json::Value::Array(vec![release.clone()]);
backend::parse_releases(&list, ASSET, |tag| tag == NIGHTLY)
/// After `version`, a Nightly, installed as an update: the old Nightly instance follows it, and the
/// older Nightlies go unless the selection or an instance still plays them. Worlds stay.
pub fn after_update(version: &str) {
let list = instances::list();
if list
.iter()
.any(|i| i.id == INSTANCE && i.version.as_deref().is_some_and(|v| date_of(v).is_some()))
{
let _ = instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()));
}
let selected = backend::Settings::load().selected;
let pinned: Vec<String> = instances::list()
.into_iter()
.next()
.filter(|r| r.prerelease)
}
/// The nightly to install: `latest` when it isn't installed yet.
pub fn update(latest: Option<Release>, installed: &[String]) -> Option<Release> {
latest.filter(|r| r.asset.is_some() && !installed.contains(&version_of(r)))
}
/// Installs `release` (the current nightly) as its own version, points the Nightly instance at
/// it (making the instance the first time) and removes older nightly versions. Returns the
/// instance.
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<Instance, String> {
let sha256 = crate::signed::nightly_digest(
ASSET.unwrap_or_default(),
&horizon_release_sign::trusted_keys(),
backend::fetch_small,
)?;
let version = version_of(release);
backend::install_as(release, &version, &sha256, progress)?;
let instance = instance_on(&version)?;
for old in backend::installed() {
if horizon_release_sign::is_nightly(&old) && old != version {
let _ = backend::remove(&old);
}
.filter_map(|i| i.version)
.collect();
for old in stale(&backend::installed(), version, selected.as_deref(), &pinned) {
let _ = backend::remove(&old);
}
Ok(instance)
}
/// The Nightly instance, made empty the first time (with the player's options and offline name),
/// playing `version`.
fn instance_on(version: &str) -> Result<Instance, String> {
if instances::find(INSTANCE).is_err() {
instances::create(NAME, None)?;
}
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
/// The installed Nightlies to remove once `current` is installed: every other one that is neither
/// selected nor pinned by an instance.
fn stale(
installed: &[String],
current: &str,
selected: Option<&str>,
pinned: &[String],
) -> Vec<String> {
installed
.iter()
.filter(|v| date_of(v).is_some() && *v != current && Some(v.as_str()) != selected)
.filter(|v| !pinned.contains(v))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn release(name: &str, date: &str, asset: bool) -> Release {
fn release(name: &str, date: &str) -> Release {
Release {
tag: NIGHTLY.into(),
name: name.into(),
body: String::new(),
date: date.into(),
asset: asset.then(|| ("https://example/horizon.zip".into(), 1)),
asset: Some(("https://example/horizon.zip".into(), 1)),
prerelease: true,
nightly: None,
}
}
#[test]
fn a_nightly_installs_under_its_date_and_commit() {
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28");
assert_eq!(version_of(&r), "nightly-2026-10-28-0c02067");
let untitled = release("Something else", "2026-10-29", true);
let untitled = release("Something else", "2026-10-29");
assert_eq!(version_of(&untitled), "nightly-2026-10-29");
// Never a release version: the release channel's checks and lists leave it out.
assert!(!backend::is_game_tag(&version_of(&r)));
assert_eq!(date_of(&version_of(&r)), Some("2026-10-28"));
assert_eq!(date_of("v0.2.95"), None);
assert_eq!(date_of("nightly"), None);
assert!(horizon_release_sign::is_nightly(&version_of(&r)));
}
#[test]
fn only_a_new_nightly_with_a_build_here_is_an_update() {
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
let name = |r: Option<Release>| r.map(|r| r.name);
fn only_unused_older_nightlies_are_removed() {
let installed = [
"nightly-2026-10-29-aaaaaaa",
"nightly-2026-10-28-bbbbbbb",
"nightly-2026-10-28-ccccccc",
"nightly-2026-10-27-ddddddd",
"v0.2.95",
]
.map(String::from);
let pinned = ["nightly-2026-10-27-ddddddd".to_string()];
assert_eq!(
name(update(Some(r.clone()), &["v0.2.80".into()])),
Some(r.name.clone())
stale(
&installed,
"nightly-2026-10-29-aaaaaaa",
Some("nightly-2026-10-28-ccccccc"),
&pinned
),
["nightly-2026-10-28-bbbbbbb"]
);
let installed = ["nightly-2026-10-28-0c02067".to_string(), "v0.2.80".into()];
assert!(update(Some(r), &installed).is_none());
let no_build = release("Nightly 2026-10-29 · 1a2b3c4", "2026-10-29", false);
assert!(update(Some(no_build), &installed).is_none());
assert!(update(None, &installed).is_none());
}
#[test]
fn only_the_nightly_pre_release_is_read() {
let json = |tag: &str, pre: bool| {
serde_json::json!({"tag_name": tag, "name": "Nightly 2026-10-28 · 0c02067",
"prerelease": pre, "published_at": "2026-10-28T20:40:00Z",
"assets": [{"name": ASSET.unwrap_or("none"), "browser_download_url": "u", "size": 1}]})
};
assert!(parse(&json("nightly", true)).is_some());
assert!(parse(&json("nightly", false)).is_none());
assert!(parse(&json("v0.2.81", true)).is_none());
}
}
+10 -1
View File
@@ -8,12 +8,21 @@ impl Launcher {
/// Whether the screen covers the window: no owner, and either Play was refused or the player
/// hasn't read it yet. The data question comes first.
pub(crate) fn asking_owner(&self) -> bool {
self.owner == Some(false)
self.owner_shown() == Some(false)
&& (self.owner_screen_open || !self.settings.owner_notice_seen)
&& !self.asking_consent()
&& self.opening.is_none()
}
/// `owner` as the window shows it: the developer option that lets offline names play without
/// an owner (`dev_options`) hides the gate, as if the keychain hadn't answered yet.
pub(crate) fn owner_shown(&self) -> Option<bool> {
match self.owner {
Some(false) if self.settings.dev_offline => None,
owner => owner,
}
}
/// Opens the screen and starts the sign-in on it.
pub(crate) fn sign_in_to_play(&mut self) {
self.owner_screen_open = true;
+176 -8
View File
@@ -8,6 +8,10 @@
//! folder. No rustup, nothing installed on the system or added to the PATH: the launcher calls this
//! cargo with its own `CARGO_HOME`. The pins come from the channel manifest of [`VERSION`]
//! (`https://static.rust-lang.org/dist/channel-rust-<VERSION>.toml`, `[pkg.<name>.target.<host>]`).
//!
//! On Windows it also brings MinGW-w64 ([`MINGW`]): `rust-mingw` only links. The game needs a C
//! compiler (`ring`, `hidapi` and wasmtime build C with `cc`), and `windows-sys`' `raw-dylib`
//! imports need `dlltool`, which runs the GNU assembler; `rust-mingw` has neither.
use crate::backend;
use std::fs;
use std::path::{Path, PathBuf};
@@ -22,8 +26,27 @@ const DIST: &str = "https://static.rust-lang.org/dist/2026-10-15";
pub struct Host {
pub target: &'static str,
components: &'static [(&'static str, &'static str)],
/// The C toolchain it brings besides Rust's components, unpacked into [`MINGW_DIR`].
mingw: Option<&'static Mingw>,
}
/// A MinGW-w64 release: one zip, pinned by its SHA-256.
pub struct Mingw {
pub version: &'static str,
url: &'static str,
sha256: &'static str,
}
/// WinLibs' build of GCC and binutils on MinGW-w64 (MSVCRT, the C runtime `rust-mingw` links
/// against; POSIX threads; SEH), the release's `mingw64/` folder without its documentation.
pub const MINGW: Mingw = Mingw {
version: "GCC 16.2.0, MinGW-w64 14.0.0 (WinLibs r3)",
url: "https://github.com/brechtsanders/winlibs_mingw/releases/download/16.2.0posix-23.1.3-14.0.0-msvcrt-r3/winlibs-x86_64-posix-seh-gcc-16.2.0-mingw-w64msvcrt-14.0.0-r3.zip",
sha256: "62af32ea58acdea1318ea5dd8eb7017eaee694dd40b269d52a59c769dcfefb82",
};
/// Where MinGW-w64 goes in the toolchain, as the zip names it.
const MINGW_DIR: &str = "mingw64";
const HOSTS: &[Host] = &[
Host {
target: "aarch64-apple-darwin",
@@ -41,6 +64,7 @@ const HOSTS: &[Host] = &[
"c124006d63885ba1029aca49efd59e93be6b2b171c379318ff94a00ef5eba664",
),
],
mingw: None,
},
Host {
target: "x86_64-unknown-linux-gnu",
@@ -58,6 +82,7 @@ const HOSTS: &[Host] = &[
"d1290c05994a59c029e562cb75be1bddc6fb11a4c501e1b7b0f61d119c6cbb12",
),
],
mingw: None,
},
Host {
target: "x86_64-pc-windows-gnu",
@@ -79,6 +104,7 @@ const HOSTS: &[Host] = &[
"30263d7bb4fd0bf63a153afe36d7fc102f929a7d42781651c6b3a03a414ba428",
),
],
mingw: Some(&MINGW),
},
];
@@ -109,13 +135,21 @@ impl Toolchain {
self.dir.join("bin").join(exe("cargo"))
}
/// MinGW-w64's programs (gcc, as, dlltool), when the toolchain brings it (Windows).
pub fn mingw_bin(&self) -> PathBuf {
self.dir.join(MINGW_DIR).join("bin")
}
/// `program` (this toolchain's cargo, or a tool built with it) set up to use only this
/// toolchain: its rustc, the launcher's Cargo home, and none of the player's rustup or
/// compiler-wrapper settings.
/// toolchain: its rustc, its MinGW-w64 where it has one, the launcher's Cargo home, and none of
/// the player's rustup or compiler-wrapper settings.
pub fn command(&self, program: &Path) -> Command {
let mut cmd = Command::new(program);
let path = std::env::var_os("PATH").unwrap_or_default();
let paths = std::iter::once(self.dir.join("bin")).chain(std::env::split_paths(&path));
let mingw = Some(self.mingw_bin()).filter(|bin| bin.is_dir());
let paths = std::iter::once(self.dir.join("bin"))
.chain(mingw)
.chain(std::env::split_paths(&path));
cmd.env("PATH", std::env::join_paths(paths).unwrap_or(path))
.env("RUSTC", self.dir.join("bin").join(exe("rustc")))
.env("CARGO_HOME", cargo_home());
@@ -164,10 +198,13 @@ fn dir_for(host: &Host) -> PathBuf {
const COMPLETE: &str = ".installed";
/// The toolchain, when it is installed.
/// The toolchain, when it is installed. A Windows one from before MinGW-w64 isn't: installing
/// again brings it.
pub fn installed() -> Option<Toolchain> {
let dir = dir_for(host()?);
dir.join(COMPLETE).is_file().then_some(Toolchain { dir })
let host = host()?;
let toolchain = Toolchain { dir: dir_for(host) };
let mingw = host.mingw.is_none() || toolchain.mingw_bin().join(exe("gcc")).is_file();
(toolchain.dir.join(COMPLETE).is_file() && mingw).then_some(toolchain)
}
/// Downloads, checks and unpacks the pinned toolchain (once); `progress` gets the fraction done.
@@ -186,7 +223,7 @@ pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
let partial = root().join(format!(".{VERSION}-{}.partial", host.target));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
let count = host.components.len() as f32;
let count = (host.components.len() + usize::from(host.mingw.is_some())) as f32;
for (i, (name, sha256)) in host.components.iter().enumerate() {
let file = format!("{name}-{VERSION}-{}.tar.gz", host.target);
let archive = root().join(format!(".{file}"));
@@ -197,6 +234,16 @@ pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
let _ = fs::remove_file(&archive);
unpacked.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
}
if let Some(mingw) = host.mingw {
let archive = root().join(".mingw.zip");
let done = host.components.len() as f32;
let step = |f: f32| progress((done + f) / count);
backend::download(mingw.url, 0, mingw.sha256, &archive, &step)
.map_err(|e| format!("MinGW-w64 ({}): {e}", mingw.version))?;
let unpacked = unpack_mingw(&archive, &partial);
let _ = fs::remove_file(&archive);
unpacked.map_err(|e| format!("MinGW-w64 ({}): {e}", mingw.version))?;
}
fs::write(partial.join(COMPLETE), VERSION).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&dir);
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
@@ -246,9 +293,50 @@ fn component_path(path: &Path) -> Option<PathBuf> {
}
}
/// Unpacks MinGW-w64's zip into `prefix`: its `mingw64/` folder, documentation left out.
fn unpack_mingw(archive: &Path, prefix: &Path) -> Result<(), String> {
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let Some(relative) = entry.enclosed_name().and_then(|p| mingw_path(&p)) else {
continue;
};
let to = prefix.join(&relative);
if entry.is_dir() {
fs::create_dir_all(&to).map_err(|e| e.to_string())?;
continue;
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut out = fs::File::create(&to).map_err(|e| format!("{}: {e}", relative.display()))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("{}: {e}", relative.display()))?;
}
Ok(())
}
/// Where a file of MinGW-w64's zip goes, relative to the prefix: `mingw64/bin/gcc.exe` as it is.
/// None for its documentation (`mingw64/share/`, 50 MB of the 925), the folder itself and anything
/// outside `mingw64/`.
fn mingw_path(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let parts: Vec<&std::ffi::OsStr> = path
.components()
.map(|c| match c {
Component::Normal(name) => Some(name),
_ => None,
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[top, first, ..] if *top == MINGW_DIR && *first != "share" => Some(parts.iter().collect()),
_ => None,
}
}
/// Whether this computer can link what the toolchain compiles: Rust brings its compiler, but uses
/// the system's linker on macOS (the Command Line Tools) and Linux (`cc`). Windows' `windows-gnu`
/// toolchain brings its own.
/// toolchain brings its own, and MinGW-w64 for C and import libraries ([`MINGW`]).
pub fn linker() -> Result<(), String> {
let ok = |program: &str, args: &[&str]| {
Command::new(program)
@@ -307,6 +395,17 @@ mod tests {
let windows = HOSTS.iter().find(|h| h.target.contains("windows")).unwrap();
assert!(windows.target.ends_with("-gnu"), "no Visual Studio needed");
assert!(windows.components.iter().any(|c| c.0 == "rust-mingw"));
// Only Windows brings a C toolchain; macOS and Linux use the system's.
for host in HOSTS {
assert_eq!(host.mingw.is_some(), host.target.contains("windows"));
}
let mingw = windows.mingw.unwrap();
assert!(mingw.sha256.len() == 64 && mingw.sha256.bytes().all(|b| b.is_ascii_hexdigit()));
assert!(mingw.url.starts_with("https://") && mingw.url.ends_with(".zip"));
assert!(
mingw.url.contains("msvcrt"),
"the C runtime rust-mingw links against"
);
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
assert_eq!(host().unwrap().target, "aarch64-apple-darwin");
}
@@ -404,4 +503,73 @@ mod tests {
);
assert!(get("CARGO_HOME").flatten().unwrap().ends_with("cargo"));
}
#[test]
fn mingw_files_land_under_mingw64_without_the_documentation() {
let at = |p: &str| mingw_path(Path::new(p));
assert_eq!(
at("mingw64/bin/gcc.exe"),
Some(PathBuf::from("mingw64/bin/gcc.exe"))
);
assert_eq!(
at("mingw64/libexec/gcc/x86_64-w64-mingw32/16.2.0/cc1.exe"),
Some(PathBuf::from(
"mingw64/libexec/gcc/x86_64-w64-mingw32/16.2.0/cc1.exe"
))
);
for skipped in [
"mingw64",
"mingw64/share/doc/gcc.html",
"other/bin/gcc.exe",
"mingw64/../evil.exe",
"/mingw64/bin/gcc.exe",
] {
assert_eq!(at(skipped), None, "{skipped}");
}
}
#[test]
fn a_mingw_zip_unpacks_its_programs_and_not_its_documentation() {
let dir = std::env::temp_dir().join(format!("horizon-mingw-unpack-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let archive = dir.join("mingw.zip");
let mut zip = zip::ZipWriter::new(fs::File::create(&archive).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.add_directory("mingw64/bin/", options).unwrap();
for (path, body) in [
("mingw64/bin/gcc.exe", "gcc"),
("mingw64/share/doc/readme.txt", "docs"),
("licenses/readme.txt", "outside"),
] {
zip.start_file(path, options).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap();
let prefix = dir.join("prefix");
unpack_mingw(&archive, &prefix).unwrap();
assert_eq!(
fs::read_to_string(prefix.join("mingw64/bin/gcc.exe")).unwrap(),
"gcc"
);
assert!(!prefix.join("mingw64/share").exists() && !prefix.join("licenses").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_command_puts_mingw_after_rust_when_the_toolchain_has_it() {
let dir = std::env::temp_dir().join(format!("horizon-mingw-path-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let toolchain = Toolchain { dir: dir.clone() };
fs::create_dir_all(toolchain.mingw_bin()).unwrap();
let cmd = toolchain.command(&toolchain.cargo());
let path = cmd
.get_envs()
.find(|(k, _)| *k == "PATH")
.and_then(|(_, v)| v)
.unwrap();
let first: Vec<PathBuf> = std::env::split_paths(path).take(2).collect();
assert_eq!(first, [dir.join("bin"), toolchain.mingw_bin()]);
fs::remove_dir_all(&dir).unwrap();
}
}
+5
View File
@@ -107,6 +107,11 @@ fn trash_stopped_server(
.map_err(|e| e.to_string())?;
lock.try_lock()
.map_err(|_| "Stop the server before deleting it.".to_string())?;
// Windows can't move a folder while a file in it is open, the lock included: let it go
// first. Still safe: a supervisor starting in between opens the lock again, and then the
// move fails instead of taking a live server.
#[cfg(windows)]
drop(lock);
trash(dir).map_err(|e| format!("Could not move the server to the trash: {e}"))
}
+6 -5
View File
@@ -30,14 +30,15 @@ pub fn digest(
horizon_release_sign::signed_digest(tag, asset, sums.as_deref(), signature.as_deref(), keys)
}
/// The signed SHA-256 of `asset` in the current nightly build (the `nightly` pre-release): its
/// checksums must be signed for the nightly channel (`horizon_release_sign::nightly_digest`).
/// The signed SHA-256 of `asset` in the Nightly published under `tag` (`nightly`, or a dated
/// `nightly-<date>`): its checksums must be signed for the nightly channel
/// (`horizon_release_sign::nightly_digest`), whatever tag serves them.
pub fn nightly_digest(
tag: &str,
asset: &str,
keys: &[PublicKey],
fetch: impl Fn(&str) -> Result<Option<Vec<u8>>, String>,
) -> Result<String, String> {
let tag = horizon_release_sign::NIGHTLY;
let sums = fetch(&asset_url(tag, SUMS))?;
let signature = fetch(&asset_url(tag, SIGNATURE))?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
@@ -131,7 +132,7 @@ mod tests {
};
let nightly = serving(horizon_release_sign::NIGHTLY);
assert_eq!(
nightly_digest("horizon.zip", &keys, &nightly),
nightly_digest("nightly", "horizon.zip", &keys, &nightly),
Ok(sha256_hex(b"game"))
);
for tag in ["v0.2.81", "nightly", "nightly-2026-10-27"] {
@@ -142,6 +143,6 @@ mod tests {
}
let release = serving("v0.2.81");
assert!(digest("v0.2.81", "horizon.zip", &keys, &release).is_ok());
assert!(nightly_digest("horizon.zip", &keys, &release).is_err());
assert!(nightly_digest("nightly", "horizon.zip", &keys, &release).is_err());
}
}
+10
View File
@@ -33,6 +33,16 @@ impl Pins {
})
}
/// Any version from Mojang's version list, by its id and its version file's sha1 (the
/// list's): the client's sha1 then comes from that checked version file.
pub fn for_version(id: &str, manifest_sha1: &str) -> Pins {
Pins {
version: id.to_string(),
client_sha1: String::new(),
manifests: vec![manifest_sha1.to_string()],
}
}
fn manifest_accepted(&self, path: &Path) -> bool {
sha1_file(path).is_some_and(|sha1| self.manifests.contains(&sha1))
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl Launcher {
pub(super) fn play_as(&mut self, who: Player) {
let mut accounts = Accounts::load();
let done = accounts
.play_as(account::store(), who)
.play_as_with(account::store(), who, self.settings.dev_offline)
.and_then(|()| accounts.save());
match done {
Ok(()) => self.accounts = accounts,
+17 -5
View File
@@ -1,5 +1,6 @@
# Installs horizon-server (docs/server.md) on Windows: the latest Horizon release, downloaded from GitHub
# Releases and checked against the SHA-256 GitHub publishes for it.
# Installs horizon-server (docs/server.md) on Windows: the current Horizon Nightly (the only build Horizon ships;
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the SHA-256
# GitHub publishes for it.
#
# irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
# & ([scriptblock]::Create((irm .../install.ps1))) -Version v0.5.0 -Dir D:\Horizon
@@ -20,10 +21,21 @@ if ($env:PROCESSOR_ARCHITECTURE -ne "AMD64") {
throw "No Horizon release for Windows on $env:PROCESSOR_ARCHITECTURE yet: build it from source (docs/server.md)"
}
$Api = if ($Version -eq "latest") { "https://api.github.com/repos/$Repo/releases/latest" }
else { "https://api.github.com/repos/$Repo/releases/tags/$Version" }
$Release = Invoke-RestMethod -Uri $Api -Headers @{ Accept = "application/vnd.github+json" }
function Get-Release([string]$Path) {
Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/$Path" -Headers @{ Accept = "application/vnd.github+json" }
}
if ($Version -eq "latest") {
try { $Release = Get-Release "tags/nightly" } catch { $Release = Get-Release "latest" }
} else {
$Release = Get-Release "tags/$Version"
}
$Tag = $Release.tag_name
# A Nightly installs under its date and commit, as `horizon-server update` names it:
# "Nightly 2026-10-28 <middle dot> 0c02067" is nightly-2026-10-28-0c02067 ([char]0xB7: this file stays ASCII).
if (($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) -and
$Release.name -match "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{1,40})$") {
$Tag = "nightly-$($Matches[1])-$($Matches[2])"
}
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
if (-not $Item) { throw "Release $Tag has no $Asset" }
if (-not $Item.digest -or -not $Item.digest.StartsWith("sha256:")) { throw "Release $Tag publishes no SHA-256 for $Asset" }
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more