Compare commits

..
Author SHA1 Message Date
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 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
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 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 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
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 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
73 changed files with 3029 additions and 424 deletions

No files matched your search

+12 -5
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), 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. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
# 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,
@@ -60,6 +61,10 @@ on:
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:
@@ -102,6 +107,7 @@ jobs:
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"
@@ -223,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)" \
@@ -264,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"
+44 -3
View File
@@ -1409,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,
@@ -5054,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;
@@ -5078,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();
@@ -11575,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]);
@@ -11795,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.
(
@@ -12105,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)
@@ -12764,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
@@ -12984,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 {
@@ -13769,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(),
@@ -14243,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() {
+1 -4
View File
@@ -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, _)) =
@@ -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.
@@ -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",
+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);
}
}
+3
View File
@@ -465,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() {
+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) => {
+76 -1
View File
@@ -19,7 +19,8 @@ use horizon_client::{
extras::Corner,
extras_hud::Corners,
hud::{HudBatch, rgba},
keybinds, mod_list,
keybinds,
mod_list::{self, Installed, NativePage},
text::Text,
};
use std::{fs, path::PathBuf, time::Duration};
@@ -66,6 +67,19 @@ pub(crate) struct NativePlayer {
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)]
@@ -122,12 +136,31 @@ impl Game<'_> {
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
@@ -241,6 +274,48 @@ impl Game<'_> {
/// 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 {
@@ -973,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),
}
}
+134 -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);
@@ -4620,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;
}
@@ -4627,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()
@@ -4801,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`).
@@ -5003,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.
@@ -5015,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
@@ -5914,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,
);
}
}
}
@@ -393,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()));
+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]
+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).
+20 -1
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)**,
@@ -53,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
@@ -93,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)).
+80 -109
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 or Nightly 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,8 +411,9 @@ 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's newest commit where
`.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
@@ -523,7 +494,7 @@ 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` 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
@@ -546,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
+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 |
+11
View File
@@ -197,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,
@@ -307,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
@@ -316,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
+5 -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!(
+24 -1
View File
@@ -1264,6 +1264,29 @@ fn download_for_owner(
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(
@@ -1369,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.
+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))?)
}
+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)"));
}
+2
View File
@@ -14,7 +14,9 @@ pub mod heads;
pub mod i18n;
pub mod import;
pub mod instances;
pub mod java;
pub mod launch;
pub mod minecraft_versions;
pub mod modpacks;
pub mod mojang;
pub mod native_mods;
+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();
}
}
+49 -12
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",
@@ -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);
}
+318 -18
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
@@ -106,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
@@ -143,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")) {
@@ -154,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
})),
}
@@ -181,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,
})
}
@@ -223,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-") {
@@ -398,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:
@@ -488,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",
@@ -513,24 +624,21 @@ pub fn build(
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| {
@@ -545,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<_>>(),
});
@@ -553,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);
@@ -610,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) {
@@ -635,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");
@@ -660,6 +850,41 @@ mod tests {
);
}
#[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");
@@ -749,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> {
@@ -772,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");
+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))
}
+5 -2
View File
@@ -114,8 +114,11 @@ class UntrustedBuildTests(unittest.TestCase):
def setUp(self):
self.game = (WORKFLOWS / "game-build.yml").read_text()
def test_only_the_release_is_trusted(self):
self.assertIn("trusted: true", (WORKFLOWS / "release.yml").read_text())
def test_no_nightly_build_is_trusted(self):
# The train only starts `nightly build` (Nightly only since 2026-10-28); it builds nothing itself.
release = (WORKFLOWS / "release.yml").read_text()
self.assertNotIn("game-build.yml", release)
self.assertIn("gh workflow run nightly-build.yml", release)
nightly_build = (WORKFLOWS / "nightly-build.yml").read_text()
self.assertIn("trusted: false", nightly_build)
self.assertNotIn("trusted: true", nightly_build)
+29
View File
@@ -269,5 +269,34 @@ class ReleaseTrainTests(unittest.TestCase):
"`shaderpacks` folder are left where they are.\n", body)
class NightlyTrainTests(unittest.TestCase):
"""Since 2026-10-28 the train decides when a Nightly goes out (release.yml starts nightly-build.yml)."""
PRS = [{"number": 1, "labels": [], "paths": ["crates/x.rs"]}]
def test_one_build_at_a_time_and_twelve_a_day(self):
self.assertEqual(train.nightly_decision(True, "5 pull requests", self.PRS, None, 3)[:2], (True, "5 pull requests"))
release, reason, _ = train.nightly_decision(True, "5", self.PRS, 4242, 3)
self.assertFalse(release)
self.assertIn("run 4242", reason)
release, reason, urgent = train.nightly_decision(True, "5", self.PRS, None, 12)
self.assertFalse(release)
self.assertFalse(urgent)
self.assertIn("12 Nightlies today", reason)
self.assertEqual(train.nightly_decision(False, "waiting", self.PRS, None, 0)[:2], (False, "waiting"))
def test_release_now_and_force_pass_the_cap_but_not_a_running_build(self):
urgent = [{"number": 2, "labels": ["release-now"], "paths": ["crates/x.rs"]}]
self.assertEqual(train.nightly_decision(True, "release-now: #2", urgent, None, 12), (True, "release-now: #2", True))
self.assertTrue(train.nightly_decision(True, "forced", self.PRS, None, 30, force=True)[0])
self.assertFalse(train.nightly_decision(True, "forced", self.PRS, 7, 0, force=True)[0])
def test_the_cap_is_the_nightly_sets(self):
from tools.ci import nightly_set
self.assertEqual(train.NIGHTLY_CAP, nightly_set.MAX_BUILDS_PER_DAY)
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""The opt-in Nightly channel (.github/workflows/nightly-build.yml, docs/release-flow.md "Nightly").
"""The Nightly, the only build Horizon ships (.github/workflows/nightly-build.yml, docs/release-flow.md "Nightly").
nightly.py pick [--force] which commit tonight's nightly builds: GitHub outputs and nightly-notes.md
nightly.py rotate renames the current `nightly` pre-release to nightly-<its date>, frees the tag
@@ -72,9 +72,9 @@ def output(**values: str) -> None:
f.write(f"{k}={v}\n")
OPT_IN = ("**Opt-in, not a release.** A Nightly can include pull requests not reviewed yet, picked by trusted "
"reviewers: it may break. Turn it on in the launcher's settings; your release install and your worlds stay "
"as they are. Report bugs with the version the game shows (\"Nightly <date> · <commit>\").")
PLAYERS = ("The launcher installs it by itself; your worlds stay as they are. A Nightly can include pull requests "
"the Team picked before they are merged (below). Report bugs with the version the game shows "
"(\"Nightly <date> · <commit>\").")
def source_tag(today: str, sha: str) -> str:
@@ -96,7 +96,7 @@ def notes(today: str, chosen: Ref, main: Ref, picked: list[dict], left_out: list
fixed = [pr for pr in shown if "feature" not in pr["labels"]]
built = f"main ({main.sha[:7]})" + (f" with {len(picked)} pull requests merged in" if picked else "")
parts = [f"Nightly {today} · {chosen.sha[:7]}: {built}, built from `{chosen.name}` ({chosen.sha}).",
source_line(today, chosen.sha), OPT_IN]
source_line(today, chosen.sha), PLAYERS]
if not shown and not picked:
parts.append("Nothing new for players on main since the previous Nightly.")
if new:
@@ -192,7 +192,7 @@ def build_set(main: Ref, prs: list[dict], left_out: list[str], today: str, force
name = "(unchanged)"
if build:
done = ns.builds_today(today)
if len(done) >= ns.MAX_BUILDS_PER_DAY and event != "schedule":
if len(done) >= ns.MAX_BUILDS_PER_DAY and event != "schedule" and os.environ.get("NIGHTLY_URGENT") != "true":
stop(repo, issue, f"{len(done)} Nightly builds today already, the most a day takes: the next scheduled "
"one (20:17 UTC) builds the set.")
name = ns.next_build(today, done)
+57 -5
View File
@@ -6,6 +6,9 @@
python3 tools/release_train.py notes --kind game --tag v2026.10.15 --out dist/notes
python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json [--blockers] # before publishing
The game ships only the Nightly since 2026-10-28 (Ludovic): when `decide --kind game` says so, release.yml starts
`nightly build` on the commit it decided (tools/ci/nightly.py), and "the last release" is the published Nightly.
A kind releases when a pull request merged into main since its last published release changed one of its
player-facing paths (GAME_PATHS, LAUNCHER_PATHS), once BATCH of them are waiting and the newest merged at least
DEBOUNCE ago (a burst of merges becomes one release), or once the oldest has waited MAX_WAIT (a lone fix still ships),
@@ -411,6 +414,45 @@ def notes(kind, version, prs, issues, previous=None):
return title, "\n\n".join(parts) + "\n"
# The workflow that builds and publishes a Nightly. NIGHTLY_CAP is tools/ci/nightly_set.py's MAX_BUILDS_PER_DAY: past
# it, the train waits for the next UTC day unless a pull request is labelled release-now or the run is forced.
NIGHTLY_WORKFLOW = "nightly-build.yml"
NIGHTLY_CAP = 12
def published_nightly():
"""The published Nightly (the `nightly` pre-release) as {tag_name, published_at}, or None."""
out = run("gh", "api", f"repos/{REPO}/releases/tags/nightly", "--jq", "{tag_name, published_at}", check=False)
return json.loads(out) if out.strip() else None
def nightly_building():
"""The run of `nightly build` under way or waiting, if any: the train starts no second one."""
runs = gh_json("run", "list", "--repo", REPO, "--workflow", NIGHTLY_WORKFLOW, "--limit", "10",
"--json", "databaseId,status")
return next((entry["databaseId"] for entry in runs if entry["status"] in ("queued", "in_progress", "waiting",
"pending", "requested")), None)
def nightly_builds_today(today):
"""The nightly/build-<date>-<n> branches of `today` (every Nightly build pushes one)."""
out = run("git", "ls-remote", "origin", f"refs/heads/nightly/build-{today}-*")
return [line for line in out.splitlines() if line.strip()]
def nightly_decision(release, reason, prs, building, built_today, force=False, cap=NIGHTLY_CAP):
"""The train's decision held back by the Nightly build: one at a time, and at most `cap` a day unless a pull
request is labelled release-now or the run is forced. Returns (release, reason, urgent)."""
urgent = force or any("release-now" in pr["labels"] for pr in prs if pr["paths"])
if not release:
return False, reason, urgent
if building:
return False, f"a Nightly is being built (run {building}): the next decision takes what it leaves", urgent
if built_today >= cap and not urgent:
return False, f"{built_today} Nightlies today already, the most a day takes: the next goes out after 00:00 UTC", urgent
return True, reason, urgent
def gather(kind, head, releases=None):
"""The last published release of this kind and the pull requests merged since, with their player-facing
paths."""
@@ -496,7 +538,12 @@ def main():
write_outputs({"release": "false", "reason": result["reason"]})
return 0
head = run("git", "rev-parse", head).strip()
last, prs = gather(args.kind, head)
nightly = published_nightly() if args.kind == "game" else None
if nightly:
run("git", "fetch", "--quiet", "--force", "origin", "refs/tags/nightly:refs/tags/nightly")
last, prs = nightly, merged_between("game", "refs/tags/nightly", head)
else:
last, prs = gather(args.kind, head)
if args.command == "notes":
version = args.tag or "unreleased"
previous = last["tag_name"] if last and last["tag_name"] != version else None
@@ -511,21 +558,26 @@ def main():
tags = run("git", "tag", "--list").split()
line = release_line()
prefix = GAME_PREFIX if args.kind == "game" else LAUNCHER_PREFIX
new_line = f"{line[0]}.{line[1]}" if line and first_on_line(tags, prefix, line) else None
new_line = f"{line[0]}.{line[1]}" if line and first_on_line(tags, prefix, line) and not nightly else None
blockers = open_blockers() if args.kind == "game" else []
release, reason = decision(args.kind, prs, now, os.environ.get("RELEASES_PAUSED") == "1", args.force, new_line,
blockers)
if release and args.head and parity and not (passed_on("parity-main", head) and passed_on("checks", head)):
release, reason = False, f"waiting for checks and parity-main to pass on {head[:9]}"
urgent = False
if args.kind == "game":
release, reason, urgent = nightly_decision(release, reason, prs, nightly_building(),
len(nightly_builds_today(now.date().isoformat())), args.force)
minor = any("feature" in pr["labels"] for pr in prs if pr["paths"])
tag = next_game_tag(tags, now.date(), line) if args.kind == "game" else next_launcher_tag(tags, minor, line)
tag = "nightly" if args.kind == "game" else next_launcher_tag(tags, minor, line)
result = {
"release": release, "reason": reason, "tag": tag, "head": head,
"release": release, "reason": reason, "tag": tag, "head": head, "urgent": urgent,
"last": last["tag_name"] if last else None, "last_published": last["published_at"] if last else None,
"pull_requests": [{key: pr[key] for key in ("number", "title", "labels", "paths")} for pr in prs],
}
print(json.dumps(result, indent=1))
write_outputs({"release": str(release).lower(), "reason": reason, "tag": tag, "head": head})
write_outputs({"release": str(release).lower(), "reason": reason, "tag": tag, "head": head,
"urgent": str(urgent).lower()})
return 0
+13
View File
@@ -95,6 +95,19 @@ def scripted(s):
s.tick(5)
s.add('stopall')
s.tick(5)
# Joining a world: the menu-like track plays, Minecraft.setLevel stops every
# sound, and the game music (not replacing) waits its own delay; a respawn
# into another dimension then stops the music manager's track too.
s.add('start', 11, 'DEFAULT')
s.situation(end, 20, 600, 1)
s.tick(110)
s.add('stopall')
s.situation(water, 12000, 24000, 0)
s.tick(3, loading=1)
s.tick(5)
s.add('stopall')
s.add('stop')
s.tick(5)
# Events that cannot play.
for event in ODD:
s.add('start', 7, 'DEFAULT')
+5 -2
View File
@@ -120,8 +120,11 @@ class Services:
if ok:
return self.reply(200, {'id': PLAYER_UUID, 'name': query['username'], 'properties': []})
return self.reply(204)
with services.lock:
services.log.append(('unknown', url.path))
# A bare `GET /` is a local tool probing every new port (T3 Code does, on the Mac mini), not
# the game: none of the services has a root.
if url.path != '/':
with services.lock:
services.log.append(('unknown', url.path))
self.reply(404, {'error': 'NotFound', 'path': url.path})
def do_POST(self):