Compare commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:50:02 +01:00
Ludovic 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 ae01d872fb Merge remote-tracking branch 'origin/main' into feat/launcher-java-instances 2026-10-28 22:26:47 +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 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 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
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 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
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
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
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
45 changed files with 2120 additions and 113 deletions

No files matched your search

+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);
+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);
}
}
+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 {
@@ -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]));
}
}
+8 -3
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;
@@ -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::*;
+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()));
+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]
+26
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).
+3
View File
@@ -313,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
+84 -7
View File
@@ -211,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 },
@@ -2134,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),
})
};
@@ -2145,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(),
@@ -2178,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();
}
+322
View File
@@ -0,0 +1,322 @@
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
//! official launcher reads it: the main class, the classpath of this system's libraries and the
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
//!
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
//! one game folder. Nothing here reaches the network.
use crate::mojang;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
pub struct Player {
pub name: String,
pub uuid: String,
pub token: String,
pub xuid: String,
}
/// Everything the command needs besides the version JSON.
pub struct Launch<'a> {
/// Holds `versions/`, `libraries/`, `assets/`.
pub minecraft: &'a Path,
/// The instance's game folder (saves, options.txt, mods).
pub game_dir: &'a Path,
/// Where an old version's natives are unpacked.
pub natives: &'a Path,
/// The `java` program.
pub java: &'a Path,
pub memory_mb: u32,
/// The player's own JVM arguments.
pub jvm_args: &'a [String],
pub player: &'a Player,
/// `osx`, `linux` or `windows`, as version files name systems.
pub os: &'a str,
}
/// This system's name in version files.
pub fn os_name() -> &'static str {
match crate::sources::Os::current() {
crate::sources::Os::Linux => "linux",
crate::sources::Os::MacOs => "osx",
crate::sources::Os::Windows => "windows",
}
}
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
/// Every optional feature (demo, custom resolution, quick play) is off.
fn allowed(rules: &Value, os: &str) -> bool {
let Some(rules) = rules.as_array() else {
return true;
};
let arch_matches = |arch: &str| match arch {
"x86" => cfg!(target_arch = "x86"),
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
_ => false,
};
let mut allow = false;
for rule in rules {
let system = &rule["os"];
let matches = system["name"].as_str().is_none_or(|n| n == os)
&& system["arch"].as_str().is_none_or(arch_matches)
&& rule["features"]
.as_object()
.is_none_or(|f| f.values().all(|v| v == false));
if matches {
allow = rule["action"] == "allow";
}
}
allow
}
/// The libraries this system uses, then the client jar.
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let mut paths: Vec<PathBuf> = Vec::new();
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
let path = minecraft.join("libraries").join(mojang::relative(path)?);
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths.push(
minecraft
.join("versions")
.join(mojang::relative(id)?)
.join(format!("{id}.jar")),
);
Ok(paths)
}
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
pub fn unpack_natives(
version: &Value,
minecraft: &Path,
os: &str,
to: &Path,
) -> Result<(), String> {
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
let Some(key) = mojang::native_classifier(library, os) else {
continue;
};
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
continue;
};
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
let exclude: Vec<&str> = library["extract"]["exclude"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let Some(name) = entry.enclosed_name() else {
continue;
};
let text = name.to_string_lossy().replace('\\', "/");
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
continue;
}
let out = to.join(&name);
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
/// the objects copied there once; the asset store itself for every other version.
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
let assets = minecraft.join("assets");
let index = assets
.join("indexes")
.join(mojang::relative(&format!("{id}.json"))?);
let Ok(text) = fs::read_to_string(&index) else {
return Ok(assets);
};
let index: Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
let to = if index["map_to_resources"] == true {
game_dir.join("resources")
} else if index["virtual"] == true {
assets.join("virtual").join(mojang::relative(id)?)
} else {
return Ok(assets);
};
for (name, object) in index["objects"].as_object().into_iter().flatten() {
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
continue;
};
let out = to.join(mojang::relative(name)?);
if !out.is_file() {
let from = assets.join("objects").join(&hash[..2]).join(hash);
fs::create_dir_all(out.parent().unwrap())
.and_then(|_| fs::copy(&from, &out))
.map_err(|e| format!("{}: {e}", from.display()))?;
}
}
Ok(to)
}
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
fn list(args: &Value, os: &str) -> Vec<String> {
let mut out = Vec::new();
for arg in args.as_array().into_iter().flatten() {
match arg {
Value::String(s) => out.push(s.clone()),
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
Value::String(s) => out.push(s.clone()),
Value::Array(values) => {
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
}
_ => {}
},
_ => {}
}
}
out
}
/// `${name}` replaced by its value; an unknown one stays as written.
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
let mut out = String::new();
let mut rest = arg;
while let Some(start) = rest.find("${") {
let Some(len) = rest[start..].find('}') else {
break;
};
out.push_str(&rest[..start]);
let key = &rest[start + 2..start + len];
match vars.get(key) {
Some(value) => out.push_str(value),
None => out.push_str(&rest[start..start + len + 1]),
}
rest = &rest[start + len + 1..];
}
out.push_str(rest);
out
}
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
/// Unpacks natives and lays out old assets first.
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let main = version["mainClass"]
.as_str()
.ok_or("The version file has no mainClass")?;
let separator = if cfg!(windows) { ";" } else { ":" };
let classpath = classpath(version, launch.minecraft, launch.os)?;
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
let assets = launch.minecraft.join("assets");
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
let path = |p: &Path| p.to_string_lossy().into_owned();
let p = launch.player;
let vars: HashMap<&str, String> = HashMap::from([
("auth_player_name", p.name.clone()),
("auth_uuid", p.uuid.clone()),
("auth_access_token", p.token.clone()),
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
("auth_xuid", p.xuid.clone()),
("clientid", String::new()),
("user_type", "msa".into()),
("user_properties", "{}".into()),
("version_name", id.to_string()),
(
"version_type",
version["type"].as_str().unwrap_or("release").into(),
),
("game_directory", path(launch.game_dir)),
("assets_root", path(&assets)),
("game_assets", path(&game_assets)),
(
"assets_index_name",
version["assetIndex"]["id"]
.as_str()
.unwrap_or("legacy")
.into(),
),
("natives_directory", path(launch.natives)),
(
"library_directory",
path(&launch.minecraft.join("libraries")),
),
("classpath_separator", separator.into()),
(
"classpath",
classpath
.iter()
.map(|c| path(c))
.collect::<Vec<_>>()
.join(separator),
),
("launcher_name", "horizon-launcher".into()),
("launcher_version", env!("CARGO_PKG_VERSION").into()),
]);
let mut jvm = vec![
format!("-Xmx{}M", launch.memory_mb),
// Whatever the logging setup, never look names up in log messages.
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
];
let logging = &version["logging"]["client"];
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
{
let config = launch
.minecraft
.join("assets")
.join("log_configs")
.join(mojang::relative(file)?);
jvm.push(arg.replace("${path}", &path(&config)));
}
let (jvm_list, game) = match version["arguments"].as_object() {
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
None => (
vec![
"-Djava.library.path=${natives_directory}".into(),
"-cp".into(),
"${classpath}".into(),
],
version["minecraftArguments"]
.as_str()
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect(),
),
};
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
jvm.extend(launch.jvm_args.iter().cloned());
let mut cmd = Command::new(launch.java);
cmd.current_dir(launch.game_dir)
.args(&jvm)
.arg(main)
.args(game.iter().map(|a| fill(a, &vars)));
Ok(cmd)
}
#[cfg(test)]
#[path = "java_launch/tests.rs"]
mod tests;
+215
View File
@@ -0,0 +1,215 @@
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
//! a scratch folder.
use super::*;
use serde_json::json;
use std::io::Write;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
/// `rel` (`a/b/c`) under `base`, one part at a time as the code joins them (Windows paths).
fn under(base: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(base.to_path_buf(), |path, part| path.join(part))
}
fn player() -> Player {
Player {
name: "Notch".into(),
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
token: "mc-token".into(),
xuid: "2535400000000000".into(),
}
}
fn args(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
fn modern() -> Value {
json!({
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "19"},
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
"--userType", "${user_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
"-cp", "${classpath}"]
},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
"file": {"id": "client-1.21.2.xml"}}},
"libraries": [
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
]
})
}
#[test]
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
let dir = scratch("modern");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let java = dir.join("java");
let p = player();
let extra = vec!["-XX:+UseZGC".to_string()];
let launch = |os| Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 6144,
jvm_args: &extra,
player: &p,
os,
};
let cmd = command(&modern(), &launch("osx")).unwrap();
let a = args(&cmd);
let at = |s: &str| {
a.iter()
.position(|x| x == s)
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
};
assert_eq!(a[0], "-Xmx6144M");
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
let config = under(&mc, "assets/log_configs/client-1.21.2.xml");
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
assert_eq!(a[at("--username") + 1], "Notch");
assert_eq!(a[at("--accessToken") + 1], "mc-token");
assert_eq!(a[at("--assetIndex") + 1], "19");
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
assert_eq!(a[at("--userType") + 1], "msa");
// Features are off: no demo, no custom size.
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
let sep = if cfg!(windows) { ";" } else { ":" };
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
let lib = |p: &str| {
under(&mc.join("libraries"), p)
.to_string_lossy()
.into_owned()
};
assert_eq!(
cp,
[
lib("a/common/1/common-1.jar"),
lib("a/mac/1/mac-1.jar"),
under(&mc, "versions/1.21.4/1.21.4.jar")
.to_string_lossy()
.into_owned()
]
);
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
let linux = args(&command(&modern(), &launch("linux")).unwrap());
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
// The x86-only rule holds on 32-bit Windows alone.
let windows = args(&command(&modern(), &launch("windows")).unwrap());
assert_eq!(
windows.contains(&"-Xss1M".into()),
cfg!(target_arch = "x86")
);
let _ = fs::remove_dir_all(&dir);
}
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
/// virtual asset index laid out by name.
#[test]
fn an_old_version_gets_its_natives_and_assets_by_name() {
let dir = scratch("old");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
let opts = zip::write::SimpleFileOptions::default();
for (name, body) in [
("liblwjgl.dylib", b"lib".as_slice()),
("META-INF/MANIFEST.MF", b"m"),
] {
zip.start_file(name, opts).unwrap();
zip.write_all(body).unwrap();
}
zip.finish().unwrap();
let hash = "0123456789abcdef0123456789abcdef01234567";
let object = mc.join("assets/objects/01").join(hash);
fs::create_dir_all(object.parent().unwrap()).unwrap();
fs::write(&object, b"ogg").unwrap();
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
fs::write(
mc.join("assets/indexes/1.8.json"),
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
.to_string(),
)
.unwrap();
let version = json!({
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "1.8"},
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
"libraries": [{
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
"natives": {"osx": "natives-osx"},
"extract": {"exclude": ["META-INF/"]},
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
}]
});
let (java, p) = (dir.join("java"), player());
let launch = Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 2048,
jvm_args: &[],
player: &p,
os: "osx",
};
let a = args(&command(&version, &launch).unwrap());
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
assert!(!natives.join("META-INF").exists());
let virtual_dir = under(&mc, "assets/virtual/1.8");
assert_eq!(
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
b"ogg"
);
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
assert_eq!(
a[at("--session") + 1],
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
);
assert_eq!(a[at("--userProperties") + 1], "{}");
assert_eq!(a[at("--unknown") + 1], "${nothing}");
assert_eq!(a[0], "-Xmx2048M");
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
// Natives are not on the classpath; the client jar is.
let cp = &a[at("-cp") + 1];
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn placeholders_fill_in_place() {
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
}
+2
View File
@@ -15,7 +15,9 @@ pub mod i18n;
pub mod import;
pub mod instances;
pub mod java;
pub mod java_launch;
pub mod launch;
pub mod minecraft_versions;
pub mod modpacks;
pub mod mojang;
pub mod native_mods;
+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();
}
}
+1 -1
View File
@@ -268,7 +268,7 @@ fn text(v: &Value, key: &str) -> Result<String, String> {
}
/// A path from the version file, refused if it could leave the folder.
fn relative(path: &str) -> Result<PathBuf, String> {
pub(crate) fn relative(path: &str) -> Result<PathBuf, String> {
let safe = !path.contains('\\')
&& Path::new(path)
.components()
+50 -4
View File
@@ -492,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:
@@ -614,10 +624,11 @@ 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);
@@ -839,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");
+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')