mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7841ccb18 | ||
|
|
c9a103852c | ||
|
|
a15faef940 | ||
|
|
58cd73ee0b | ||
|
|
e2a3e92424 | ||
|
|
6f8c7a0e5d | ||
|
|
04cbda1b14 | ||
|
|
2de8bbd84b | ||
|
|
a7177cfbd7 | ||
|
|
b558a845a0 | ||
|
|
9f1a998403 | ||
|
|
527cd7225c | ||
|
|
f64fdeaa75 | ||
|
|
a0c79c8b26 | ||
|
|
0284d3bb16 | ||
|
|
72b4d8ecbb | ||
|
|
1ed31e54eb | ||
|
|
b3aaa04129 | ||
|
|
bb38695695 | ||
|
|
6bda112225 | ||
|
|
77fa9fb89f | ||
|
|
153d18a102 | ||
|
|
e470f3a0c9 | ||
|
|
f93c28a230 | ||
|
|
b9721d816d | ||
|
|
2034db1df6 | ||
|
|
5091896e2a | ||
|
|
6edc4e950d | ||
|
|
d4120a9ef0 | ||
|
|
795a0db7e4 | ||
|
|
75b8a176d4 | ||
|
|
21a0656530 | ||
|
|
071580e716 | ||
|
|
ae01d872fb | ||
|
|
d2f5a0d678 | ||
|
|
d6ee462614 | ||
|
|
430e5e4c92 | ||
|
|
f3d8535fcd | ||
|
|
559ecb02f6 | ||
|
|
11b8757bc7 | ||
|
|
b44da42ca4 | ||
|
|
9669600c38 | ||
|
|
528c322d16 | ||
|
|
a9f8e64331 | ||
|
|
01d0f45c2d | ||
|
|
228af35f2a | ||
|
|
abd1755f95 | ||
|
|
868256f1a8 | ||
|
|
1149c92cb8 | ||
|
|
41266ae70b | ||
|
|
4e804cd813 | ||
|
|
e2f3d1a85a | ||
|
|
99008baad9 | ||
|
|
e1bc2cbea3 | ||
|
|
7b93b310a0 | ||
|
|
0112a6feac | ||
|
|
9d32bebad3 | ||
|
|
e24394ca3a | ||
|
|
a5ee1e588c | ||
|
|
752fc2170a | ||
|
|
356b69aea7 | ||
|
|
fdefcfeac0 | ||
|
|
9fcfdaa1ea | ||
|
|
3be828d012 | ||
|
|
1deb3bd36c | ||
|
|
2ad7b68710 | ||
|
|
203fb2db89 | ||
|
|
2f7edd1524 | ||
|
|
32a0a11fed | ||
|
|
0580626784 | ||
|
|
65bca4ce98 | ||
|
|
2812cf201d | ||
|
|
9bbc54bd4c | ||
|
|
4fdc93257f | ||
|
|
b38fe43b09 | ||
|
|
35581f52e2 | ||
|
|
c216561daa | ||
|
|
618d20bd08 | ||
|
|
985ece811b | ||
|
|
c734c72fc9 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 |
No files matched your search
@@ -1,11 +1,12 @@
|
||||
name: nightly build
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
|
||||
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
|
||||
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
|
||||
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release. Launchers install it only for players
|
||||
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
|
||||
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
@@ -60,6 +61,10 @@ on:
|
||||
description: The commit of main to build on (default main's newest with green checks and parity-main)
|
||||
type: string
|
||||
default: ''
|
||||
urgent:
|
||||
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
@@ -102,6 +107,7 @@ jobs:
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
NIGHTLY_URGENT: ${{ inputs.urgent }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -223,7 +229,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
@@ -264,7 +271,7 @@ jobs:
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
|
||||
+27
-139
@@ -1,22 +1,15 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
|
||||
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
|
||||
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
|
||||
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
|
||||
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
|
||||
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
|
||||
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
|
||||
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
|
||||
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
|
||||
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
|
||||
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
@@ -28,17 +21,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
description: Decide only; start no Nightly build
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
# The checks, parity-main and nightly build runs (decide).
|
||||
actions: read
|
||||
concurrency:
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
@@ -54,9 +49,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,127 +70,21 @@ jobs:
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# The version the programs are built with (docs/versioning.md): the tag without its v.
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
nightly:
|
||||
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
|
||||
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# Every platform's archive, and macOS's smoke test (game-build.yml).
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
version: ${{ needs.decide.outputs.version }}
|
||||
trusted: true # main: the release runner, the release cache, no shared sccache
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
actions: write # starts `nightly build`
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ needs.decide.outputs.head }}
|
||||
URGENT: ${{ needs.decide.outputs.urgent }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
|
||||
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1409,6 +1409,8 @@ struct Game<'a> {
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
dialogs: dialogs::DialogState,
|
||||
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
|
||||
integrated_starting: Option<integrated::Starting>,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -5054,6 +5056,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
@@ -5078,6 +5081,11 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
@@ -11575,6 +11583,27 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
// the window doesn't see yet: the title alone, over the menu background.
|
||||
if self.integrated_starting.is_some() {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
let (w, h) = (hud.width, hud.height);
|
||||
horizon_client::ui::menu_background(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
false,
|
||||
);
|
||||
horizon_client::level_loading::draw(&mut hud, &self.gui.atlas, None, None);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
@@ -11795,7 +11824,8 @@ impl Game<'_> {
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
(
|
||||
@@ -12105,7 +12135,8 @@ impl Game<'_> {
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
fn menu_blur(&self) -> Option<f32> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
@@ -12764,7 +12795,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.perf.cancel_world_opening();
|
||||
return;
|
||||
}
|
||||
if (self.pending_entry.is_some() || self.world_loader.is_some())
|
||||
if (self.pending_entry.is_some()
|
||||
|| self.world_loader.is_some()
|
||||
|| self.integrated_starting.is_some())
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
@@ -12984,6 +13017,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
// ConnectScreen's Cancel, under the prompts and dialogs above.
|
||||
WindowEvent::MouseInput { state, button, .. } if self.remote_loading() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.connect_screen_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -13769,6 +13808,7 @@ impl<'a> Game<'a> {
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -14243,6 +14283,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if !loader.ready() {
|
||||
|
||||
@@ -1029,10 +1029,7 @@ impl Game<'_> {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
self.ui.difficulty = self.level.difficulty_id();
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
|
||||
@@ -2573,6 +2573,9 @@ impl Game<'_> {
|
||||
)));
|
||||
}
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
|
||||
@@ -131,6 +131,8 @@ impl Game<'_> {
|
||||
map.insert("item".into(), save::item_to_nbt(item));
|
||||
}
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
// The sherds and item go to the guests (BlockEntityData).
|
||||
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
|
||||
self.consume_held();
|
||||
self.audio.play(
|
||||
"minecraft:block.decorated_pot.insert",
|
||||
|
||||
@@ -169,6 +169,16 @@ enum Publish {
|
||||
/// of the connection and the world's name, or why it failed.
|
||||
type Ready = std::result::Result<(Pipe, String), String>;
|
||||
|
||||
/// An integrated server loading its world: the window keeps drawing its
|
||||
/// loading screen and joins once the server is ready, as vanilla's client
|
||||
/// does while its server prepares (`Minecraft.doWorldLoad`), instead of
|
||||
/// waiting for it frozen for the whole load.
|
||||
pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
struct Launch {
|
||||
folder: String,
|
||||
@@ -197,8 +207,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.published().is_some())
|
||||
}
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
|
||||
/// until it can be joined, then join it through memory.
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
|
||||
/// window joins it through memory once it is ready (`poll_integrated_start`).
|
||||
pub(super) fn open_integrated(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -267,20 +277,53 @@ impl Game<'_> {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
let mut handle = Integrated {
|
||||
let handle = Integrated {
|
||||
flags,
|
||||
thread: Some(thread),
|
||||
tasks,
|
||||
world_name: String::new(),
|
||||
};
|
||||
let pipe = match ready.recv() {
|
||||
Ok(Ok((pipe, world_name))) => {
|
||||
handle.world_name = world_name;
|
||||
pipe
|
||||
self.integrated_starting = Some(Starting {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
while self.integrated_starting.is_some() {
|
||||
self.poll_integrated_start()?;
|
||||
if self.integrated_starting.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err("the integrated server stopped".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The starting server's answer, polled each frame: joined once it is ready.
|
||||
pub(super) fn poll_integrated_start(&mut self) -> Result<()> {
|
||||
let Some(starting) = &self.integrated_starting else {
|
||||
return Ok(());
|
||||
};
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.integrated_starting = None;
|
||||
return Err("the integrated server stopped".into());
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
@@ -64,6 +65,10 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != Hand::Main {
|
||||
return false;
|
||||
}
|
||||
self.eject_record(pos);
|
||||
return true;
|
||||
}
|
||||
@@ -100,6 +105,30 @@ impl Game<'_> {
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `JukeboxBlock.useItemOn` on this client's level for the main hand:
|
||||
/// whether the jukebox at `pos` takes the click (`has_record` pops the
|
||||
/// disc out, or a `jukebox_playable` item goes in). The server does the
|
||||
/// rest, see `use_jukebox`.
|
||||
pub(super) fn jukebox_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
|
||||
return false;
|
||||
}
|
||||
let has_record = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_record"))
|
||||
.is_some_and(|v| v == "true");
|
||||
has_record
|
||||
|| held.is_some_and(|stack| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.jukebox_song.is_some())
|
||||
})
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
self.stop_sound(pos);
|
||||
|
||||
@@ -2040,6 +2040,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| (g.chunk(), radius))
|
||||
.chain(
|
||||
self.arrival_chunks(self.dim)
|
||||
.into_iter()
|
||||
// An arrival keeps only its landing spot lit enough to
|
||||
// resolve (the 3x3 `resolve_arrivals` checks), where it
|
||||
// then ticks without a player.
|
||||
.map(|chunk| (chunk, 1)),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -1038,12 +1038,23 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
|
||||
// under spawn protection (the ack puts the block back).
|
||||
if self.under_spawn_protection(host, gi, a.pos) {
|
||||
self.spawn_protection_message(host, conn, a.pos);
|
||||
return;
|
||||
}
|
||||
// mayInteract: a block outside the world border can't be
|
||||
// dug (the ack puts it back on the client).
|
||||
if !self
|
||||
.border
|
||||
.contains(f64::from(a.pos[0]), f64::from(a.pos[2]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
@@ -1070,6 +1081,15 @@ impl Game<'_> {
|
||||
1 => {}
|
||||
3 => {
|
||||
host.hub.ack(conn, a.sequence);
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
// Too far (handleBlockBreakAction's "too far"): the
|
||||
// dig drops and the ack has the client undo its
|
||||
// prediction.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = None;
|
||||
play.mining = None;
|
||||
return;
|
||||
}
|
||||
self.guest_stop_digging(host, conn, a.pos);
|
||||
}
|
||||
2 => {
|
||||
@@ -1162,6 +1182,14 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
let inside = p
|
||||
.cursor
|
||||
.iter()
|
||||
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
|
||||
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
|
||||
return;
|
||||
}
|
||||
let face = direction(p.face);
|
||||
let hit = Hit {
|
||||
pos: p.pos,
|
||||
@@ -1175,9 +1203,14 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
// handleUseItemOn: under spawn protection nothing is used or
|
||||
// placed; the player is told and the blocks below go back.
|
||||
// mayInteract: nothing is used on a block outside the world
|
||||
// border either.
|
||||
if self.under_spawn_protection(host, gi, p.pos) {
|
||||
self.spawn_protection_message(host, conn, p.pos);
|
||||
} else {
|
||||
} else if self
|
||||
.border
|
||||
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
|
||||
{
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
@@ -1384,6 +1417,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
p.difficulty as u8
|
||||
};
|
||||
// The level data holds the world's difficulty: it
|
||||
// survives the reopen, so it follows the change.
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
@@ -3041,10 +3077,10 @@ impl Game<'_> {
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// Entity.take: the clients play the pickup sound themselves
|
||||
// (handleTakeItemEntity).
|
||||
for (item, count) in taken {
|
||||
host.hub.take_item(item as i32, id, count as i32, position);
|
||||
host.hub
|
||||
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,9 @@ impl Game<'_> {
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
// A pending portal arrival keeps its destination level
|
||||
// running so it can land and tick (the portal ticket).
|
||||
|| !self.arrival_chunks(*d).is_empty()
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
|
||||
@@ -267,6 +267,7 @@ impl Game<'_> {
|
||||
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
|
||||
);
|
||||
}
|
||||
self.list_native_mods();
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
@@ -302,8 +303,14 @@ impl Game<'_> {
|
||||
.filter_map(|p| horizon_mods::Package::read(p).ok())
|
||||
.map(|p| p.manifest.id)
|
||||
.collect();
|
||||
// A native mod of the same id (one built into this game) takes over.
|
||||
let mut native = Vec::new();
|
||||
self.native_mods(&mut native);
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let on = |id: &str| {
|
||||
(enabled.contains(id) || dev_ids.iter().any(|d| d == id))
|
||||
&& !native.iter().any(|n| n.id == id)
|
||||
};
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
@@ -349,6 +356,7 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -365,9 +373,11 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
native: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list_native_mods();
|
||||
}
|
||||
/// The chat's tree after the running mods changed.
|
||||
fn sync_mod_commands(&mut self) {
|
||||
|
||||
@@ -45,14 +45,28 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
}
|
||||
/// `Minecraft.setLevel` -> `updateLevelInEngines`: `SoundManager.stop()`.
|
||||
/// The music manager keeps its stopped track and, on its next tick, waits
|
||||
/// the level's music delay before the next one.
|
||||
pub(super) fn stop_sounds_for_new_level(&mut self) {
|
||||
self.ambience.clear(&mut self.audio);
|
||||
self.audio.stop_all();
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
pub(super) fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
let empty = BackgroundMusic::default();
|
||||
// The title screen has no player (and no level for the camera probe).
|
||||
// A server's client has its player from handleLogin on, before the
|
||||
// first teleport makes it here: the camera is still at the origin.
|
||||
let eye = if self.title.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.eye_position()
|
||||
self.eye_position().or_else(|| {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.logged_in)
|
||||
.then_some([0.0; 3])
|
||||
})
|
||||
};
|
||||
let (background, probe_volume) = match eye {
|
||||
Some(eye) => {
|
||||
|
||||
@@ -19,7 +19,8 @@ use horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
hud::{HudBatch, rgba},
|
||||
keybinds, mod_list,
|
||||
keybinds,
|
||||
mod_list::{self, Installed, NativePage},
|
||||
text::Text,
|
||||
};
|
||||
use std::{fs, path::PathBuf, time::Duration};
|
||||
@@ -66,6 +67,19 @@ pub(crate) struct NativePlayer {
|
||||
pub(crate) dimension: &'static str,
|
||||
}
|
||||
|
||||
/// A native mod as Options > Mods lists it (`native_mods`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeMod {
|
||||
/// Its id: its settings folder and its keys' lines.
|
||||
pub(crate) id: String,
|
||||
/// `Name 1.0`.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
/// Its keys: name, label and default key (`key.keyboard.z`). The page
|
||||
/// lists them first; `native_mod_key` reads the player's choice.
|
||||
pub(crate) keys: Vec<[String; 3]>,
|
||||
}
|
||||
|
||||
/// The mappings `native_mapping` answers for, with the key their default
|
||||
/// binding is (none: a mouse button, attack or use).
|
||||
#[allow(dead_code)]
|
||||
@@ -122,12 +136,31 @@ impl Game<'_> {
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
let _ = minimap;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
}
|
||||
/// The buttons of mod `mod_id`'s settings page, under its keys: push
|
||||
/// their labels when it is yours. Asked again after each click.
|
||||
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
let _ = (mod_id, buttons);
|
||||
}
|
||||
/// Button `button` of mod `mod_id`'s settings page was clicked.
|
||||
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
|
||||
let _ = (mod_id, button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
|
||||
/// turned off does nothing (check this first in each hook) until the
|
||||
/// launcher's next build leaves it out.
|
||||
pub(super) fn native_on(&self, mod_id: &str) -> bool {
|
||||
!self.settings.mods.disabled.contains(mod_id)
|
||||
}
|
||||
/// Playing, with nothing over the world: keys and zoom apply.
|
||||
pub(super) fn native_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
@@ -241,6 +274,48 @@ impl Game<'_> {
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// Options > Mods' rows for the native mods, after the others.
|
||||
pub(super) fn list_native_mods(&mut self) {
|
||||
let mut mods = Vec::new();
|
||||
self.native_mods(&mut mods);
|
||||
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
|
||||
// native row stands for it, and alone toggles its `mod.<id>` line.
|
||||
self.settings
|
||||
.mods
|
||||
.installed
|
||||
.retain(|row| row.native.is_none() && !mods.iter().any(|m| m.id == row.id));
|
||||
for m in mods {
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&m.id, &mut buttons);
|
||||
let page = NativePage {
|
||||
keys: m.keys,
|
||||
buttons,
|
||||
};
|
||||
self.settings.mods.installed.push(Installed {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(page),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A click on button `button` of native mod `i`'s page (its index in
|
||||
/// Options > Mods), and the page's labels after it.
|
||||
pub(super) fn native_settings_event(&mut self, i: usize, button: usize) {
|
||||
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.native_settings_click(&id, button);
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&id, &mut buttons);
|
||||
let row = self.settings.mods.installed.get_mut(i);
|
||||
if let Some(page) = row.and_then(|m| m.native.as_mut()) {
|
||||
page.buttons = buttons;
|
||||
}
|
||||
}
|
||||
/// A key event for `native_key`.
|
||||
pub(super) fn native_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat {
|
||||
|
||||
@@ -973,6 +973,18 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
/// the area loaded without a player.
|
||||
pub(super) fn arrival_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
|
||||
self.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.filter(|a| a.plan.dim == dim)
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
|
||||
@@ -246,6 +246,7 @@ impl Game<'_> {
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
menu::Input,
|
||||
mobs::{Mob, MobType, Who},
|
||||
@@ -115,6 +115,9 @@ pub(super) struct Remote {
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
joined: bool,
|
||||
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
|
||||
/// while here the player is made at the first teleport.
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
@@ -303,6 +306,7 @@ impl Game<'_> {
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
super::world_loading::preload_server_loot(&self.options.resources);
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -453,6 +457,7 @@ impl Game<'_> {
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
joined: false,
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
@@ -1046,6 +1051,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
// A new ClientLevel: Minecraft.setLevel stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
@@ -1148,6 +1155,23 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// A decorated pot is no block entity here: its tag is kept with the chunk.
|
||||
if name == "minecraft:decorated_pot" {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !fields.is_empty()
|
||||
{
|
||||
let mut full = fields.clone();
|
||||
full.insert("id".into(), Tag::String(name.into()));
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
|
||||
// The section is drawn again with the pot's sherds.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(entity) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
@@ -1162,7 +1186,22 @@ impl Game<'_> {
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
|
||||
if let Some((_, mut loaded)) =
|
||||
horizon_client::save::decode_block_entity(&Tag::Compound(full))
|
||||
{
|
||||
// The turn of a vault's item and a trial spawner's mob is the client's
|
||||
// own, not in the tag: an update keeps it (VaultClientData).
|
||||
match (self.world.block_entities.get(&key), &mut loaded) {
|
||||
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1733,6 +1772,7 @@ impl Game<'_> {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.logged_in = true;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
@@ -1753,7 +1793,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
let (music, _) = self.situational_music();
|
||||
self.music.stop_playing(music.as_ref(), &mut self.audio);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = None;
|
||||
@@ -2234,21 +2279,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::TakeItemEntity(p) => {
|
||||
// An orb's sound comes from the server, at the orb.
|
||||
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
|
||||
// the item, whoever collects it (the server sends none). An
|
||||
// orb's sound comes from the server, at the orb; an arrow this
|
||||
// client does not track as an item sounds at this player.
|
||||
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
|
||||
if !orb
|
||||
&& self
|
||||
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
|
||||
let at = match item {
|
||||
Some(d) => Some([d.position.x, d.position.y, d.position.z]),
|
||||
None if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.entity_id == p.player)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
.is_some_and(|r| r.entity_id == p.player) =>
|
||||
{
|
||||
self.eye_position()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !orb && let Some(at) = at {
|
||||
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
|
||||
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
|
||||
}
|
||||
// ItemPickupParticle toward the collector (this player, another one or a mob).
|
||||
let collector = if self
|
||||
@@ -2349,6 +2403,17 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 3 on this player: its death
|
||||
// sound, which Player.playSound plays for this client alone
|
||||
// (the server's goes to everyone else).
|
||||
if p.event == 3
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
&& let Some(pl) = self.player.as_ref()
|
||||
{
|
||||
let at = [pl.position.x, pl.position.y, pl.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
@@ -4620,6 +4685,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
@@ -4627,9 +4693,26 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
|
||||
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
|
||||
/// player has infinite materials.
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
|
||||
let swings = self.swings_started;
|
||||
let done = self.remote_use_item_on(hand);
|
||||
if done
|
||||
&& self.swings_started != swings
|
||||
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
|
||||
&& (Some(after) != before || self.creative)
|
||||
{
|
||||
self.heights.item_used(hand);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
|
||||
/// (the block reacts, or a block item is placed).
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
|
||||
let held = self.with_hand(hand, |g| g.held().cloned());
|
||||
let held_block = held
|
||||
.as_ref()
|
||||
@@ -4801,6 +4884,9 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5003,7 +5089,9 @@ impl Game<'_> {
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
fn remote_use_hands(&mut self) {
|
||||
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
|
||||
if self.remote_use_item(Hand::Main) {
|
||||
self.heights.item_used(Hand::Main);
|
||||
return;
|
||||
}
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
@@ -5015,7 +5103,9 @@ impl Game<'_> {
|
||||
if off_target {
|
||||
return;
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
if self.remote_use_item(Hand::Off) {
|
||||
self.heights.item_used(Hand::Off);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
@@ -5914,26 +6004,42 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
let shown = horizon_client::net::shown_address(&r.session.address);
|
||||
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
batch.text(
|
||||
atlas,
|
||||
line,
|
||||
(w - tw) / 2.0,
|
||||
h / 2.0 - 12.0 + i as f32 * 12.0,
|
||||
if i == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
},
|
||||
true,
|
||||
);
|
||||
if r.joined {
|
||||
// LevelLoadingScreen: the world's terrain comes in.
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
horizon_client::level_loading::draw(batch, atlas, None, None);
|
||||
return true;
|
||||
}
|
||||
let _ = r.joined;
|
||||
let cancel = self.connect_cancel((w, h)).is_some();
|
||||
let status = horizon_client::text::Text::literal(r.status.as_str());
|
||||
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
|
||||
true
|
||||
}
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
self.remote.is_some() && self.player.is_none()
|
||||
}
|
||||
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
|
||||
/// joined through `ConnectScreen`), until the login is done.
|
||||
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
(self.player.is_none() && !r.joined && self.integrated.is_none())
|
||||
.then(|| horizon_client::level_loading::connect_cancel(w, h))
|
||||
}
|
||||
/// A click on `ConnectScreen`: Cancel aborts the connection
|
||||
/// (`connect.aborted`) and goes back. Escape does nothing there
|
||||
/// (`shouldCloseOnEsc` is false).
|
||||
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
|
||||
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.leave_server(None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The server's death message; chat uses the shared ChatComponent.
|
||||
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
|
||||
let Some(r) = &self.remote else { return };
|
||||
|
||||
@@ -93,4 +93,45 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
/// packet brings it.
|
||||
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
|
||||
return;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let ages = match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if ages {
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +393,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
Event::ModSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,27 @@ pub(super) fn world_data_used() {
|
||||
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
|
||||
}
|
||||
|
||||
/// A server's world opens: the loot tables its first placed block or broken
|
||||
/// container needs (`loot_data`) are read on a helper thread while the
|
||||
/// connection is made, so the game thread never waits on them (~0.6 s on a
|
||||
/// phone). Kept from then on, as a local world keeps them.
|
||||
pub(super) fn preload_server_loot(resources: &std::path::Path) {
|
||||
world_data_used();
|
||||
if horizon_client::structure_blocks::loaded_loot_data().is_some() {
|
||||
return;
|
||||
}
|
||||
let resources = resources.to_owned();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Loot preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: loot preload: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
|
||||
@@ -28,6 +28,14 @@ pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.byId`: an unknown id is white (`OutOfBoundsStrategy.ZERO`).
|
||||
fn dye_name(id: i32) -> &'static str {
|
||||
usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| crate::block_entity_render::DYES.get(i))
|
||||
.map_or("white", |d| d.0)
|
||||
}
|
||||
|
||||
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
|
||||
Arc::from(
|
||||
builtin
|
||||
@@ -61,6 +69,25 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
// BannerPatternLayers: each layer's pattern is a synced registry entry.
|
||||
Component::BannerPatterns(layers) => {
|
||||
let layers: Option<Vec<Tag>> = layers
|
||||
.iter()
|
||||
.map(|(pattern, color)| {
|
||||
let pattern = synced.name("minecraft:banner_pattern", *pattern)?;
|
||||
Some(Tag::compound([
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
("color", Tag::String(dye_name(*color).into())),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let Some(layers) = layers else {
|
||||
continue;
|
||||
};
|
||||
Tag::List(layers)
|
||||
}
|
||||
// A shield's `base_color`: the dye's id.
|
||||
Component::Int(v) if *kind == component::BASE_COLOR => Tag::String(dye_name(*v).into()),
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
|
||||
@@ -824,6 +824,7 @@ impl ChunkEncoder {
|
||||
tag: block_entities
|
||||
.get(&pos)
|
||||
.and_then(entity_tag)
|
||||
.or_else(|| pot_tag(chunk, pos))
|
||||
.unwrap_or(Tag::End),
|
||||
});
|
||||
}
|
||||
@@ -938,6 +939,28 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if !b.layers.is_empty() {
|
||||
entries.push(("patterns", crate::banner::layers_to_nbt(&b.layers)));
|
||||
}
|
||||
if let Some(name) = &b.name {
|
||||
entries.push(("CustomName", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("components", Tag::Compound(b.components.clone())));
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
// VaultBlockEntity.getUpdateTag: the shared data, which holds the display item.
|
||||
BlockEntity::Vault(v) => Some(v.update_tag()),
|
||||
// TrialSpawnerStateData.getUpdateTag: the spawn data the mob is drawn from.
|
||||
// next_mob_spawns_at (ACTIVE only) is left out: nothing in the client
|
||||
// reads it, and the block state it depends on is not known here.
|
||||
BlockEntity::TrialSpawner(t) => t
|
||||
.next_spawn_data
|
||||
.as_ref()
|
||||
.map(|d| Tag::compound([("spawn_data", d.encode())])),
|
||||
// SkullBlockEntity: the profile is the whole reason a head differs.
|
||||
BlockEntity::Skull(s) => {
|
||||
let mut entries = Vec::new();
|
||||
@@ -962,6 +985,22 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The update tag of the decorated pot at `pos` (DecoratedPotBlockEntity.getUpdateTag,
|
||||
/// saveCustomOnly). A pot is not a block entity here: its tag is the chunk's saved one.
|
||||
pub fn pot_tag(chunk: &Chunk, pos: (i32, i32, i32)) -> Option<Tag> {
|
||||
let at = chunk.saved.as_ref()?.block_entities.iter().find(|t| {
|
||||
t.get("id") == Some(&Tag::String("minecraft:decorated_pot".into()))
|
||||
&& t.get("x") == Some(&Tag::Int(pos.0))
|
||||
&& t.get("y") == Some(&Tag::Int(pos.1))
|
||||
&& t.get("z") == Some(&Tag::Int(pos.2))
|
||||
})?;
|
||||
let entries: Vec<_> = ["sherds", "LootTable", "LootTableSeed", "item"]
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, at.get(k)?.clone())))
|
||||
.collect();
|
||||
(!entries.is_empty()).then(|| Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// Light sections run from one below the world to one above it. A layer
|
||||
/// exists where vanilla's light storage keeps one: within one section of a
|
||||
/// non-air section of this column or a neighbouring one (`around`, bit i for
|
||||
|
||||
@@ -381,7 +381,7 @@ fn serve_legacy(mut stream: TcpStream, shared: &Shared) -> Result<(), String> {
|
||||
let body = if data.len() == 1 {
|
||||
format!("{motd}\u{a7}{online}\u{a7}{max}")
|
||||
} else {
|
||||
if data[1] != 1 {
|
||||
if data.get(1) != Some(&1) {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > 2 {
|
||||
|
||||
@@ -459,6 +459,22 @@ impl Guest {
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(eye, range + buffer, pos)
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
|
||||
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
|
||||
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
|
||||
/// guest's body in its pose (crouching, swimming, gliding).
|
||||
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
|
||||
let eye = self
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(
|
||||
[self.position[0], self.position[1] + eye, self.position[2]],
|
||||
range + 1.0,
|
||||
pos,
|
||||
)
|
||||
}
|
||||
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
|
||||
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
|
||||
/// 45 off hand.
|
||||
@@ -523,6 +539,14 @@ pub fn firework_components(
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.getId` of a dye's name.
|
||||
fn dye_id(name: &str) -> Option<i32> {
|
||||
crate::block_entity_render::DYES
|
||||
.iter()
|
||||
.position(|d| d.0 == name)
|
||||
.map(|i| i as i32)
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
@@ -595,6 +619,36 @@ fn network_stack(
|
||||
));
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers: each pattern a reference into the synced registry;
|
||||
// a layer that has none leaves the stack without the component.
|
||||
let layers: Option<Vec<(i32, i32)>> = d
|
||||
.banner_patterns
|
||||
.iter()
|
||||
.map(|layer| {
|
||||
let name = if layer.pattern.contains(':') {
|
||||
layer.pattern.to_string()
|
||||
} else {
|
||||
format!("minecraft:{}", layer.pattern)
|
||||
};
|
||||
Some((
|
||||
synced_id("minecraft:banner_pattern", &name)?,
|
||||
dye_id(&layer.color)?,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if let Some(layers) = layers.filter(|layers| !layers.is_empty()) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BANNER_PATTERNS,
|
||||
horizon_protocol::item::Component::BannerPatterns(layers),
|
||||
));
|
||||
}
|
||||
// A shield's `base_color`.
|
||||
if let Some(id) = d.base_color.as_deref().and_then(dye_id) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BASE_COLOR,
|
||||
horizon_protocol::item::Component::Int(id),
|
||||
));
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -771,4 +825,43 @@ mod tests {
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
|
||||
fn guest_at(position: [f64; 3]) -> Guest {
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 0,
|
||||
uuid: 0,
|
||||
name: "guest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: String::new(),
|
||||
sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:1".parse().unwrap(),
|
||||
},
|
||||
1,
|
||||
position,
|
||||
10,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_action_must_be_within_reach() {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
// Survival (4.5 + 1 from the eyes): five blocks off horizontally,
|
||||
// not one further.
|
||||
assert!(g.may_reach_block([5, 64, 0]));
|
||||
assert!(!g.may_reach_block([6, 64, 0]));
|
||||
// Creative's 5.0 reaches one block further.
|
||||
g.game_mode = CREATIVE;
|
||||
assert!(g.may_reach_block([6, 64, 0]));
|
||||
assert!(!g.may_reach_block([7, 64, 0]));
|
||||
// With no body yet the sneak flag gives the eye height: a block at
|
||||
// the edge above is gone once crouched.
|
||||
g.game_mode = SURVIVAL;
|
||||
assert!(g.may_reach_block([0, 71, 0]));
|
||||
g.sneaking = true;
|
||||
assert!(!g.may_reach_block([0, 71, 0]));
|
||||
}
|
||||
}
|
||||
@@ -1566,10 +1566,15 @@ impl Hub {
|
||||
}
|
||||
// Block entities with data the client shows.
|
||||
for &(x, y, z) in &positions {
|
||||
let Some(entity) = world.block_entities.get(&(x, y, z)) else {
|
||||
continue;
|
||||
// A decorated pot is no block entity: its tag is the chunk's saved one.
|
||||
let tag = match world.block_entities.get(&(x, y, z)) {
|
||||
Some(entity) => super::chunks::entity_tag(entity),
|
||||
None => world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.and_then(|c| super::chunks::pot_tag(c, (x, y, z))),
|
||||
};
|
||||
let Some(tag) = super::chunks::entity_tag(entity) else {
|
||||
let Some(tag) = tag else {
|
||||
continue;
|
||||
};
|
||||
let state = world.block(x, y, z).0;
|
||||
@@ -2216,13 +2221,16 @@ impl Hub {
|
||||
// game's, run after this pass. A move queued behind it is checked
|
||||
// against the block the player just broke and falls through, so it
|
||||
// would be sent back; it waits for the next tick, when the block is
|
||||
// gone, as vanilla handles the two packets in order.
|
||||
// gone, as vanilla handles the packets in order. So does everything
|
||||
// queued before that move: the arm swing (Punch) comes between the dig
|
||||
// and the move.
|
||||
S::PlayerAction(p) if p.action <= 3 => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
|
||||
if matches!(
|
||||
self.guests[i].inbox.front(),
|
||||
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
|
||||
) {
|
||||
if self.guests[i]
|
||||
.inbox
|
||||
.iter()
|
||||
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,35 @@ pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progres
|
||||
batch.fill(atlas, left, top, done, 2.0, opaque(0x00FF00));
|
||||
}
|
||||
|
||||
/// `ConnectScreen`'s Cancel button corner on a GUI `w` x `h`:
|
||||
/// (width / 2 - 100, height / 4 + 120 + 12), 200 x 20.
|
||||
pub fn connect_cancel(w: f32, h: f32) -> (f32, f32) {
|
||||
((w / 2.0).trunc() - 100.0, (h / 4.0).trunc() + 132.0)
|
||||
}
|
||||
|
||||
/// `ConnectScreen.extractRenderState` over the menu background: the status
|
||||
/// centred at (width / 2, height / 2 - 50) and, while the connection can
|
||||
/// still be cancelled, Cancel.
|
||||
pub fn draw_connecting(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
status: &Text,
|
||||
cursor: (f32, f32),
|
||||
cancel: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let tw = atlas.font.width_text(status);
|
||||
let (x, y) = (((w - tw) / 2.0).trunc(), (h / 2.0).trunc() - 50.0);
|
||||
batch.text_styled(atlas, status, x, y, u32::MAX, true);
|
||||
if cancel {
|
||||
let (x, y) = connect_cancel(w, h);
|
||||
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
|
||||
let label = Text::translate("gui.cancel", &[]);
|
||||
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1962,6 +1962,7 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
|
||||
Family::Guardian => super::guardian::travel(m, w, input, events),
|
||||
// Shulkers never move on their own.
|
||||
Family::Shulker => true,
|
||||
Family::Allay => super::allay::travel(m, w, input, events),
|
||||
Family::Vex => super::vex::travel(m),
|
||||
Family::Nautilus => super::nautilus::travel(m, w, input, events),
|
||||
_ => false,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! blocks and bring them to their player (or a liked note block), dance to
|
||||
//! jukeboxes and duplicate when given an amethyst shard mid-dance.
|
||||
use super::{
|
||||
Mob,
|
||||
Mob, MobEvent, MobWorld,
|
||||
anim::{Poses, State},
|
||||
control::MoveKind,
|
||||
goals::{Cx, Goal, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone},
|
||||
nav::NavKind,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
|
||||
// Allay state: `main_hand` the held item, `ints[1]` how many it carries,
|
||||
@@ -133,6 +134,14 @@ impl Goal for StayClose {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
|
||||
/// the one the move control set this tick, and no gravity applies, so an
|
||||
/// allay never falls under its own travel.
|
||||
pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>) -> bool {
|
||||
let speed = m.ai.speed;
|
||||
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
|
||||
true
|
||||
}
|
||||
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
|
||||
pub fn ai_step(m: &mut Mob) {
|
||||
if m.alive() && m.age.is_multiple_of(10) {
|
||||
|
||||
@@ -773,6 +773,8 @@ pub struct Stroll {
|
||||
pub water_avoiding: Option<f32>,
|
||||
/// RandomSwimmingGoal: BehaviorUtils.getRandomSwimmablePos.
|
||||
pub swim: bool,
|
||||
/// WaterAvoidingRandomFlyingGoal: a hover spot, else an air or water pos.
|
||||
pub flying: bool,
|
||||
pub force: bool,
|
||||
wanted: [f64; 3],
|
||||
}
|
||||
@@ -784,6 +786,7 @@ impl Stroll {
|
||||
check_no_action: true,
|
||||
water_avoiding: None,
|
||||
swim: false,
|
||||
flying: false,
|
||||
force: false,
|
||||
wanted: [0.0; 3],
|
||||
}
|
||||
@@ -800,7 +803,10 @@ impl Stroll {
|
||||
}
|
||||
/// WaterAvoidingRandomFlyingGoal (a nearby air position).
|
||||
pub fn flying(speed: f64) -> Self {
|
||||
Self::new(speed)
|
||||
Self {
|
||||
flying: true,
|
||||
..Self::new(speed)
|
||||
}
|
||||
}
|
||||
/// RandomSwimmingGoal(speed, interval)
|
||||
pub fn swimming(speed: f64, interval: i32) -> Self {
|
||||
@@ -861,6 +867,21 @@ impl Goal for Stroll {
|
||||
}
|
||||
p
|
||||
}
|
||||
None if self.flying => {
|
||||
// WaterAvoidingRandomFlyingGoal.getPosition.
|
||||
let [xd, _, zd] = m.view_vector();
|
||||
m.hover_random_pos(
|
||||
cx.w,
|
||||
8,
|
||||
7,
|
||||
xd,
|
||||
zd,
|
||||
f64::from(std::f32::consts::FRAC_PI_2),
|
||||
3,
|
||||
1,
|
||||
)
|
||||
.or_else(|| m.air_and_water_random_pos(cx.w, 8, 4, -2, xd, zd))
|
||||
}
|
||||
None => m.default_random_pos(cx.w, 10, 7),
|
||||
};
|
||||
let Some(p) = pos else { return false };
|
||||
|
||||
@@ -935,12 +935,33 @@ impl Mob {
|
||||
} else {
|
||||
0.02
|
||||
};
|
||||
self.travel_flying_speeds(
|
||||
w,
|
||||
input,
|
||||
events,
|
||||
fluid_speed,
|
||||
fluid_speed,
|
||||
self.kind.air_speed,
|
||||
);
|
||||
}
|
||||
/// `LivingEntity.travelFlying(input, waterSpeed, lavaSpeed, airSpeed)`:
|
||||
/// the speeds vary per mob (an allay flies at the speed its move control
|
||||
/// set, `Allay.travel`).
|
||||
pub fn travel_flying_speeds(
|
||||
&mut self,
|
||||
w: &MobWorld,
|
||||
input: Vec3,
|
||||
events: &mut Vec<MobEvent>,
|
||||
water_speed: f32,
|
||||
lava_speed: f32,
|
||||
air_speed: f32,
|
||||
) {
|
||||
let (speed, drag) = if self.fluids.in_water {
|
||||
(fluid_speed, f64::from(0.8_f32))
|
||||
(water_speed, f64::from(0.8_f32))
|
||||
} else if self.fluids.in_lava {
|
||||
(fluid_speed, 0.5)
|
||||
(lava_speed, 0.5)
|
||||
} else {
|
||||
(self.kind.air_speed, f64::from(0.91_f32))
|
||||
(air_speed, f64::from(0.91_f32))
|
||||
};
|
||||
self.move_relative(speed, input);
|
||||
self.do_move(w, events);
|
||||
|
||||
@@ -1456,8 +1456,12 @@ fn hand_base(
|
||||
| Species::WitherSkeleton
|
||||
);
|
||||
let arm = if left { "left_arm" } else { "right_arm" };
|
||||
// `SkeletonModel.translateToHand` shifts the arm by 1, `PlayerModel.translateToHand`
|
||||
// by 0.5 for slim arms: the hand is the middle of the arm.
|
||||
let offset = if skeleton {
|
||||
if left { -1.0 } else { 1.0 }
|
||||
} else if avatar(m) && m.slim {
|
||||
if left { -0.5 } else { 0.5 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Which of the installed Horizon mods are on (`docs/mods.md`), kept in
|
||||
//! `horizon-mods.txt` beside `options.txt` as `mod.<id>:true` lines. Every
|
||||
//! mod is off until the player turns it on in Options > Mods.
|
||||
//! mod is off until the player turns it on in Options > Mods. A native mod is
|
||||
//! built in, so on until turned off (`mod.<id>:false`); the launcher builds
|
||||
//! only the native mods whose line is `true`.
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
@@ -15,6 +17,9 @@ pub const DIR: &str = "horizon-mods";
|
||||
pub struct ModList {
|
||||
/// Ids of the mods the player turned on.
|
||||
pub enabled: BTreeSet<String>,
|
||||
/// Ids of the native mods the player turned off: they rest
|
||||
/// (`native_on`) until the launcher's next build leaves them out.
|
||||
pub disabled: BTreeSet<String>,
|
||||
/// The keys the player chose for mods' keys, mod id → key name → key
|
||||
/// (`key.<id>.<name>:key.keyboard.x` lines).
|
||||
pub keys: BTreeMap<String, BTreeMap<String, String>>,
|
||||
@@ -36,6 +41,18 @@ pub struct Installed {
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
}
|
||||
|
||||
/// A native mod's settings page: its keys as Key Binds rows, then its
|
||||
/// buttons. The keys the player chooses are kept in `ModList::keys`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NativePage {
|
||||
/// Each key's name, label and default key (`key.keyboard.z`).
|
||||
pub keys: Vec<[String; 3]>,
|
||||
pub buttons: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModList {
|
||||
@@ -45,13 +62,15 @@ impl ModList {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let enabled = text
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.filter(|(_, v)| v.trim() == "true")
|
||||
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let ids = |value: &str| -> BTreeSet<String> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.filter(|(_, v)| v.trim() == value)
|
||||
.filter_map(|(k, _)| k.trim().strip_prefix("mod."))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
let (enabled, disabled) = (ids("true"), ids("false"));
|
||||
let mut keys: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||||
for (k, v) in text.lines().filter_map(|l| l.split_once(':')) {
|
||||
// Mod ids have no dots; a key's name may.
|
||||
@@ -66,17 +85,19 @@ impl ModList {
|
||||
}
|
||||
Self {
|
||||
enabled,
|
||||
disabled,
|
||||
keys,
|
||||
installed: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let on = self.enabled.iter().map(|id| format!("mod.{id}:true\n"));
|
||||
let off = self.disabled.iter().map(|id| format!("mod.{id}:false\n"));
|
||||
let keys = self.keys.iter().flat_map(|(id, keys)| {
|
||||
keys.iter()
|
||||
.map(move |(name, key)| format!("key.{id}.{name}:{key}\n"))
|
||||
});
|
||||
on.chain(keys).collect()
|
||||
on.chain(off).chain(keys).collect()
|
||||
}
|
||||
/// Mod `id`'s chosen keys, for `Mods::set_bindings`.
|
||||
pub fn keys_of(&self, id: &str) -> Vec<(&str, &str)> {
|
||||
@@ -86,22 +107,40 @@ impl ModList {
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE);
|
||||
if self.enabled.is_empty() && self.keys.is_empty() && !path.exists() {
|
||||
if self.enabled.is_empty()
|
||||
&& self.disabled.is_empty()
|
||||
&& self.keys.is_empty()
|
||||
&& !path.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(path, self.serialize());
|
||||
}
|
||||
pub fn is_on(&self, i: usize) -> bool {
|
||||
self.installed
|
||||
.get(i)
|
||||
.is_some_and(|m| m.dev || self.enabled.contains(&m.id))
|
||||
self.installed.get(i).is_some_and(|m| {
|
||||
m.dev
|
||||
|| if m.native.is_some() {
|
||||
!self.disabled.contains(&m.id)
|
||||
} else {
|
||||
self.enabled.contains(&m.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Turns installed mod `i` on or off (a dev mod stays on).
|
||||
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
|
||||
/// line says `false` when off, so that the launcher's next build leaves
|
||||
/// it out, and `true` when on again.
|
||||
pub fn toggle(&mut self, i: usize) {
|
||||
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
|
||||
return;
|
||||
};
|
||||
if !self.enabled.remove(&m.id) {
|
||||
if m.native.is_some() {
|
||||
if self.disabled.remove(&m.id) {
|
||||
self.enabled.insert(m.id.clone());
|
||||
} else {
|
||||
self.enabled.remove(&m.id);
|
||||
self.disabled.insert(m.id.clone());
|
||||
}
|
||||
} else if !self.enabled.remove(&m.id) {
|
||||
self.enabled.insert(m.id.clone());
|
||||
}
|
||||
}
|
||||
@@ -132,7 +171,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggles_installed_mods_but_not_dev_ones() {
|
||||
fn toggles_installed_and_native_mods_but_not_dev_ones() {
|
||||
let installed = |id: &str, dev| Installed {
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
@@ -140,6 +179,7 @@ mod tests {
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
installed: vec![installed("a", false), installed("b", true)],
|
||||
@@ -152,5 +192,18 @@ mod tests {
|
||||
assert_eq!(list.serialize(), "mod.a:true\n");
|
||||
list.toggle(0);
|
||||
assert!(!list.is_on(0));
|
||||
list.installed.push(Installed {
|
||||
native: Some(NativePage::default()),
|
||||
..installed("c", false)
|
||||
});
|
||||
// On until turned off; off, the launcher leaves it out.
|
||||
assert!(list.is_on(2));
|
||||
list.toggle(2);
|
||||
assert!(!list.is_on(2));
|
||||
assert_eq!(list.serialize(), "mod.c:false\n");
|
||||
assert_eq!(ModList::parse(&list.serialize()).disabled, list.disabled);
|
||||
list.toggle(2);
|
||||
assert!(list.is_on(2));
|
||||
assert_eq!(list.serialize(), "mod.c:true\n");
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,34 @@ mod tests {
|
||||
assert_eq!(m.current(&engine), Some("minecraft:music.menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_level_stops_menu_music_and_waits_for_game_music() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut m = MusicManager::new(2);
|
||||
let menu = Music::menu();
|
||||
m.start_playing(
|
||||
&menu,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
// Minecraft.setLevel -> SoundManager.stop(); the next tick has a player.
|
||||
engine.stop_all(&mut backend);
|
||||
let game = Music::game();
|
||||
m.tick(
|
||||
Some(&game),
|
||||
1.0,
|
||||
true,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
assert!(m.current_instance().is_none());
|
||||
assert!((12000..=24000).contains(&m.next_song_delay()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_music_volume_fades_out_and_stops() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
|
||||
@@ -262,13 +262,15 @@ impl Session {
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// `Connection.disconnect`: the socket closes and the game goes on at once.
|
||||
/// The reader thread is left to end on its own: on Windows a shutdown
|
||||
/// doesn't wake a read already waiting, so joining it held the window until
|
||||
/// the server dropped the connection, many seconds later.
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(w) = self.writer.lock().unwrap().take() {
|
||||
w.shutdown();
|
||||
}
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
drop(self.thread.take());
|
||||
}
|
||||
}
|
||||
impl Drop for Session {
|
||||
|
||||
@@ -820,6 +820,9 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `selected_key` while a native mod's key waits (`selected_native_key`).
|
||||
const NATIVE_KEY: &str = "horizon.nativeModKey";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Page {
|
||||
Main,
|
||||
@@ -834,6 +837,8 @@ pub enum Page {
|
||||
Font,
|
||||
Packs,
|
||||
Mods,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
@@ -872,6 +877,9 @@ enum Act {
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
@@ -931,6 +939,9 @@ pub enum Event {
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Button `.1` of native mod `.0`'s page was pressed: the game tells the
|
||||
/// mod and refreshes the page.
|
||||
NativeButton(usize, usize),
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
@@ -963,8 +974,11 @@ pub struct OptionsUi {
|
||||
slider_value: Option<(Opt, f32, Instant)>,
|
||||
/// The pointer's last y while a scrollbar is held.
|
||||
pub(crate) scrollbar_drag_y: Option<f32>,
|
||||
/// Key Binds: the mapping waiting for a key.
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Controller Binds: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
@@ -1002,6 +1016,7 @@ impl Default for OptionsUi {
|
||||
slider_value: None,
|
||||
scrollbar_drag_y: None,
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
languages: Vec::new(),
|
||||
@@ -1167,6 +1182,7 @@ impl OptionsUi {
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
@@ -1225,6 +1241,43 @@ impl OptionsUi {
|
||||
filtered
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::Extras => {
|
||||
@@ -1506,7 +1559,7 @@ impl OptionsUi {
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings {
|
||||
if installed.has_settings || installed.native.is_some() {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
@@ -1526,6 +1579,7 @@ impl OptionsUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
@@ -2556,8 +2610,17 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::ModSettings(i));
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::PickShaderPack(i) => {
|
||||
if let Some(pack) = s.shader_packs.get(i) {
|
||||
@@ -2755,6 +2818,18 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
@@ -3100,6 +3175,7 @@ mod tests {
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
|
||||
@@ -2673,6 +2673,21 @@ pub struct Level {
|
||||
pub player_extra: Option<Tag>,
|
||||
}
|
||||
impl Level {
|
||||
/// `PrimaryLevelData.setDifficulty`: the level data holds the world's
|
||||
/// difficulty, so the game and the file follow each other and a world
|
||||
/// that was set to hard is hard when it is opened again.
|
||||
pub fn set_difficulty(&mut self, difficulty: u8) {
|
||||
self.difficulty = crate::title::DIFFICULTIES[difficulty as usize % 4].to_owned();
|
||||
}
|
||||
|
||||
/// `PrimaryLevelData.getDifficulty`: the id of the level data's
|
||||
/// difficulty (peaceful 0 .. hard 3), normal when it is not one of them.
|
||||
pub fn difficulty_id(&self) -> u8 {
|
||||
crate::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.difficulty)
|
||||
.map_or(2, |i| i as u8)
|
||||
}
|
||||
/// Initialize the offline single-player identity before saving or dropping items.
|
||||
pub fn ensure_player_uuid(&mut self) -> [i32; 4] {
|
||||
*self.player_uuid.get_or_insert_with(|| {
|
||||
@@ -4916,6 +4931,36 @@ mod tests {
|
||||
section(Tag::Byte(0), None, Some(Tag::ByteArray(varied))),
|
||||
]));
|
||||
}
|
||||
/// #2571: the difficulty is a property of the world: the level data
|
||||
/// holds it, so a world that was set to hard is hard when it is opened
|
||||
/// again, and a world that was downloaded keeps the difficulty it was
|
||||
/// made with.
|
||||
#[test]
|
||||
fn the_level_data_holds_the_difficulty() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-difficulty-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut level = Level {
|
||||
name: "Test".into(),
|
||||
..Level::default()
|
||||
};
|
||||
// The world's difficulty is the level data's.
|
||||
level.set_difficulty(3);
|
||||
assert_eq!(level.difficulty, "hard");
|
||||
assert_eq!(level.difficulty_id(), 3);
|
||||
// It survives the save, so the world comes back with it.
|
||||
level.write(&dir).unwrap();
|
||||
let back = Level::read(&dir).unwrap();
|
||||
assert_eq!(back.difficulty, "hard");
|
||||
assert_eq!(back.difficulty_id(), 3);
|
||||
// A world whose difficulty is not one of ours is normal.
|
||||
let other = Level {
|
||||
difficulty: "extreme".into(),
|
||||
..Level::default()
|
||||
};
|
||||
assert_eq!(other.difficulty_id(), 2);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_round_trips_through_the_vanilla_files() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-level-{}", std::process::id()));
|
||||
|
||||
@@ -407,6 +407,54 @@ fn sprint_commands_survive_keyboard_input_changes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_behind_a_dig_and_its_swing_waits_for_the_next_tick() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Digger", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
s.hub.guests[0].loaded = true;
|
||||
let at = s.hub.guests[0].position;
|
||||
let below = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32 - 1,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
// A creative break as the client sends it in one tick: the dig, the arm swing,
|
||||
// then the move into the block it predicted gone.
|
||||
let inbox = &mut s.hub.guests[0].inbox;
|
||||
inbox.push_back(S::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: below,
|
||||
direction: 1,
|
||||
sequence: 1,
|
||||
}));
|
||||
inbox.push_back(S::Punch(Empty));
|
||||
inbox.push_back(S::MovePlayerPos(play::MovePlayerPos {
|
||||
x: at[0],
|
||||
y: at[1] - 0.0784,
|
||||
z: at[2],
|
||||
flags: 0,
|
||||
}));
|
||||
s.tick(&[]);
|
||||
// The game breaks the block after the hub's pass: the move waits, or it would be
|
||||
// checked against the block and sent back.
|
||||
let g = &s.hub.guests[0];
|
||||
assert_eq!(g.position, at, "the move was handled before the dig");
|
||||
assert!(
|
||||
matches!(g.inbox.back(), Some(S::MovePlayerPos(_))),
|
||||
"the move left the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_join() {
|
||||
let Some(resources) = resources() else {
|
||||
|
||||
@@ -15,6 +15,12 @@ pub struct Manifest {
|
||||
/// Exact source revisions tested together, never mutable tags.
|
||||
pub horizon: String,
|
||||
pub mixins: String,
|
||||
/// The version of Horizon's native hook points (`game/native_hooks.rs`,
|
||||
/// `NATIVE_HOOKS_API`) a mod that hooks nothing else needs: the launcher
|
||||
/// builds it on any Horizon with that version or a later one, and
|
||||
/// `horizon` is only the revision it was tested on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hooks: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn revision(value: &str) -> bool {
|
||||
@@ -32,6 +38,7 @@ impl Manifest {
|
||||
|| self.game.is_empty()
|
||||
|| !revision(&self.horizon)
|
||||
|| !revision(&self.mixins)
|
||||
|| self.hooks == Some(0)
|
||||
{
|
||||
return Err(
|
||||
"invalid native manifest: schema 1, id, version, game and exact revisions required"
|
||||
@@ -154,6 +161,7 @@ mod tests {
|
||||
game: "26.3".into(),
|
||||
horizon: "a".repeat(40),
|
||||
mixins: "b".repeat(40),
|
||||
hooks: None,
|
||||
}
|
||||
}
|
||||
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
|
||||
@@ -235,4 +243,22 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hooks_is_optional_and_kept() {
|
||||
let hooked = Manifest {
|
||||
hooks: Some(1),
|
||||
..manifest()
|
||||
};
|
||||
assert!(hooked.check().is_ok());
|
||||
let text = serde_json::to_string(&hooked).unwrap();
|
||||
assert!(text.contains("\"hooks\":1"), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Manifest>(&text).unwrap(), hooked);
|
||||
let plain = serde_json::to_string(&manifest()).unwrap();
|
||||
assert!(!plain.contains("hooks"), "{plain}");
|
||||
let zero = Manifest {
|
||||
hooks: Some(0),
|
||||
..manifest()
|
||||
};
|
||||
assert!(zero.check().is_err());
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,9 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
|
||||
let mut versions = Vec::new();
|
||||
for v in &entry.versions {
|
||||
match version(entry, v, src) {
|
||||
// A mod that turned native keeps its `.hmod` versions published, but the index offers
|
||||
// only its native ones: a launcher never installs a `.hmod` as a native mod.
|
||||
Ok(_) if file_kind(entry, v) != entry.kind => {}
|
||||
Ok(v) => versions.push(v),
|
||||
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
|
||||
}
|
||||
@@ -235,6 +238,15 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind of a version's file: the project's, but a `.hmod` of a mod that turned native
|
||||
/// (`review`) is a WebAssembly mod's.
|
||||
fn file_kind(entry: &Entry, v: &EntryVersion) -> Kind {
|
||||
match entry.kind {
|
||||
Kind::NativeMod if v.file.ends_with(".hmod") => Kind::Mod,
|
||||
_ => entry.kind.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, String> {
|
||||
if !plain_version(&v.version) {
|
||||
return Err("a version is letters, digits, '.', '-' and '_'".into());
|
||||
@@ -256,7 +268,7 @@ fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result<Version, St
|
||||
(None, Some(_)) => return Err("signed, but the project has no key".into()),
|
||||
(None, None) => {}
|
||||
}
|
||||
let facts = inspect(&entry.kind, &path)?;
|
||||
let facts = inspect(&file_kind(entry, v), &path)?;
|
||||
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
|
||||
return Err(format!(
|
||||
"the file's id is {:?}, not {:?}",
|
||||
@@ -343,8 +355,9 @@ pub struct Review {
|
||||
|
||||
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
|
||||
/// a published file never changes and is never removed (take it down in
|
||||
/// `revoked.toml`), a project's kind never changes, and changes by someone
|
||||
/// who isn't an owner, or to owners and keys, are pointed out.
|
||||
/// `revoked.toml`), a project's kind never changes but for a mod turning
|
||||
/// native (its next versions are `.hnmod`s), and changes by someone who
|
||||
/// isn't an owner, or to owners and keys, are pointed out.
|
||||
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
|
||||
let mut r = Review::default();
|
||||
for old in base {
|
||||
@@ -365,7 +378,12 @@ pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
|
||||
old.owners.join(", ")
|
||||
));
|
||||
}
|
||||
if new.kind != old.kind {
|
||||
if (&old.kind, &new.kind) == (&Kind::Mod, &Kind::NativeMod) {
|
||||
r.notes.push(format!(
|
||||
"{}: turns native; its .hmod versions stay published, and launchers keep the installed copies",
|
||||
old.id
|
||||
));
|
||||
} else if new.kind != old.kind {
|
||||
r.errors.push(format!("{}: its kind can't change", old.id));
|
||||
}
|
||||
for v in &old.versions {
|
||||
|
||||
@@ -243,6 +243,33 @@ fn a_review_protects_published_files() {
|
||||
assert_eq!(r.notes, ["cozy: a new modpack by ludovic111"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_may_turn_native_once() {
|
||||
let base: Entry = toml::from_str(&hud_entry()).unwrap();
|
||||
let mut native = base.clone();
|
||||
native.kind = Kind::NativeMod;
|
||||
let mut next = native.versions[0].clone();
|
||||
next.version = "2.0.0".into();
|
||||
next.file = "coords_hud-2.0.0.hnmod".into();
|
||||
native.versions.insert(0, next);
|
||||
let r = review(
|
||||
std::slice::from_ref(&base),
|
||||
std::slice::from_ref(&native),
|
||||
"ludovic111",
|
||||
);
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert!(r.notes.iter().any(|n| n.contains("turns native")), "{r:?}");
|
||||
// Its .hmod versions are a WebAssembly mod's files; the new ones native.
|
||||
assert_eq!(file_kind(&native, &native.versions[1]), Kind::Mod);
|
||||
assert_eq!(file_kind(&native, &native.versions[0]), Kind::NativeMod);
|
||||
// And never back.
|
||||
let r = review(&[native], &[base], "ludovic111");
|
||||
assert!(
|
||||
r.errors.iter().any(|e| e.contains("kind can't change")),
|
||||
"{r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_open_kinds_are_taken() {
|
||||
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! serialized LevelChunkSections with their PalettedContainers, block
|
||||
//! entities) and ClientboundLightUpdatePacketData (sky/block DataLayers).
|
||||
use crate::{
|
||||
Result,
|
||||
Error, Result,
|
||||
buf::{BitSet, Bytes, Codec, Longs, Reader, VarInt, Writer},
|
||||
packet,
|
||||
};
|
||||
@@ -83,6 +83,11 @@ impl Paletted {
|
||||
pub fn read(r: &mut Reader, strategy: Strategy, global_bits: u8) -> Result<Self> {
|
||||
let wire = r.u8()?;
|
||||
let (has_palette, bits) = strategy.configuration(wire, global_bits);
|
||||
// SimpleBitStorage holds 1 to 32 bits; wider wire values would
|
||||
// divide by zero or overflow a shift.
|
||||
if bits > 32 {
|
||||
return Err(Error::new(format!("{bits} bits per entry, more than 32")));
|
||||
}
|
||||
let palette = if !has_palette {
|
||||
Vec::new()
|
||||
} else if bits == 0 {
|
||||
@@ -425,6 +430,23 @@ mod tests {
|
||||
assert_eq!(g.get(4095), 4095 * 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_bits_wider_than_storage_are_rejected() {
|
||||
for wire in [33u8, 200] {
|
||||
let data = [wire];
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(Paletted::read(&mut r, Strategy::Blocks, 16).is_err());
|
||||
}
|
||||
// 15- and 16-bit global palettes still read (1024 longs of zeroes).
|
||||
for (wire, global) in [(15u8, 15u8), (16, 16)] {
|
||||
let mut data = vec![wire];
|
||||
data.resize(1 + 1024 * 8, 0);
|
||||
let mut r = Reader::new(&data);
|
||||
let p = Paletted::read(&mut r, Strategy::Blocks, global).unwrap();
|
||||
assert_eq!(p.bits, wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_data_round_trips() {
|
||||
let mut states = vec![0; BLOCKS];
|
||||
|
||||
@@ -187,6 +187,9 @@ fn ipv4_first(mut addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
|
||||
addrs.sort_by_key(SocketAddr::is_ipv6);
|
||||
addrs
|
||||
}
|
||||
/// RFC 8305's connection attempt delay: how long an address has alone before the next
|
||||
/// one is tried beside it.
|
||||
const ATTEMPT_DELAY: Duration = Duration::from_millis(250);
|
||||
/// Both halves before the split.
|
||||
pub struct Connection {
|
||||
pub reader: ConnReader,
|
||||
@@ -206,19 +209,54 @@ impl Connection {
|
||||
let addrs = crate::resolver::resolve(host, port)?;
|
||||
Self::connect_addresses(host, addrs, timeout)
|
||||
}
|
||||
/// Each address starts once the earlier ones fail or [`ATTEMPT_DELAY`] after the last
|
||||
/// start, alongside those still trying, and the first to connect wins: a dead address
|
||||
/// in a round-robin set costs 250 ms instead of the whole timeout.
|
||||
fn connect_addresses(host: &str, addrs: Vec<SocketAddr>, timeout: Duration) -> Result<Self> {
|
||||
// Report the first address's failure: it is the one vanilla tries, and the IPv6
|
||||
// fallbacks after it often fail only because the client has no IPv6 route.
|
||||
let mut first = None;
|
||||
for addr in ipv4_first(addrs) {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => return Self::from_stream(stream),
|
||||
Err(e) => {
|
||||
first.get_or_insert_with(|| Error::new(format!("Connection refused: {e}")));
|
||||
let addrs = ipv4_first(addrs);
|
||||
let (send, recv) = std::sync::mpsc::channel();
|
||||
let mut errors: Vec<Option<std::io::Error>> = addrs.iter().map(|_| None).collect();
|
||||
let (mut started, mut pending) = (0, 0);
|
||||
loop {
|
||||
if let Some(&addr) = addrs.get(started) {
|
||||
let (send, index) = (send.clone(), started);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("horizon-connect".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send((index, TcpStream::connect_timeout(&addr, timeout)));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => pending += 1,
|
||||
Err(e) => errors[index] = Some(e),
|
||||
}
|
||||
started += 1;
|
||||
}
|
||||
if pending == 0 {
|
||||
if started < addrs.len() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let reply = if started < addrs.len() {
|
||||
match recv.recv_timeout(ATTEMPT_DELAY) {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
recv.recv().expect("a sender is held here")
|
||||
};
|
||||
pending -= 1;
|
||||
match reply {
|
||||
(_, Ok(stream)) => return Self::from_stream(stream),
|
||||
(index, Err(e)) => errors[index] = Some(e),
|
||||
}
|
||||
}
|
||||
Err(first.unwrap_or_else(|| Error::new(format!("Unknown host {host}"))))
|
||||
// Report the first address's failure: it is the one vanilla tries, and the IPv6
|
||||
// fallbacks after it often fail only because the client has no IPv6 route.
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(e) => Err(Error::new(format!("Connection refused: {e}"))),
|
||||
None => Err(Error::new(format!("Unknown host {host}"))),
|
||||
}
|
||||
}
|
||||
pub fn from_stream(stream: TcpStream) -> Result<Self> {
|
||||
stream.set_nodelay(true)?;
|
||||
@@ -281,6 +319,13 @@ impl ConnReader {
|
||||
loop {
|
||||
if let Some(frame) = frame::split(&mut self.buffer)? {
|
||||
let body = frame::decompress(frame, self.threshold)?;
|
||||
if body.is_empty() {
|
||||
// A frame with no packet in it. Vanilla's PacketDecoder
|
||||
// only decodes a packet from a readable buffer, so an
|
||||
// empty compressed frame is skipped and the connection
|
||||
// stays up.
|
||||
continue;
|
||||
}
|
||||
let mut r = Reader::new(&body);
|
||||
let id = r.varint()?;
|
||||
let start = r.position();
|
||||
@@ -400,6 +445,42 @@ mod tests {
|
||||
assert_eq!(peer(&c), v6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_address_does_not_hold_up_the_next() {
|
||||
// TEST-NET-1 never answers (or is unreachable at once).
|
||||
let dead: SocketAddr = "192.0.2.1:25565".parse().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let live = listener.local_addr().unwrap();
|
||||
let start = Instant::now();
|
||||
let c = Connection::connect_addresses("test", vec![dead, live], Duration::from_secs(10))
|
||||
.unwrap();
|
||||
assert_eq!(peer(&c), live);
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"{:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_failure_reports_the_first_address() {
|
||||
let closed = |_| {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
};
|
||||
let error = |host, addrs| {
|
||||
Connection::connect_addresses(host, addrs, Duration::from_secs(5))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let refused = error("test", (0..3).map(closed).collect());
|
||||
assert!(refused.starts_with("Connection refused"), "{refused}");
|
||||
assert_eq!(error("nowhere", Vec::new()), "Unknown host nowhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_with_compression_and_encryption() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
@@ -496,4 +577,30 @@ mod tests {
|
||||
assert_eq!(c.recv().unwrap(), (5, vec![4, 3]));
|
||||
client.join().unwrap();
|
||||
}
|
||||
|
||||
/// A server may send a compressed frame that carries no packet: the
|
||||
/// uncompressed length is 0 and the frame holds nothing else. Vanilla
|
||||
/// skips it (its PacketDecoder only reads a packet from a readable
|
||||
/// buffer), so the client stays connected.
|
||||
#[test]
|
||||
fn an_empty_compressed_frame_is_skipped() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut c = Connection::from_stream(stream).unwrap();
|
||||
c.set_compression(64);
|
||||
// The empty frame: its length prefix, then the uncompressed
|
||||
// length 0 and nothing else.
|
||||
c.writer.send_contents(&[0]).unwrap();
|
||||
let mut body = vec![3];
|
||||
body.extend_from_slice(b"after");
|
||||
c.writer.send(&body).unwrap();
|
||||
});
|
||||
let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
|
||||
c.set_compression(64);
|
||||
let (id, payload) = c.recv().unwrap();
|
||||
assert_eq!((id, payload.as_slice()), (3, &b"after"[..]));
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! zlib compression above a threshold (CompressionEncoder / CompressionDecoder).
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Reader, varint_size, write_varint},
|
||||
buf::{Reader, write_varint},
|
||||
};
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use std::io::{Read, Write};
|
||||
@@ -65,6 +65,11 @@ pub fn split(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
|
||||
};
|
||||
len |= usize::from(b & 0x7F) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
if len == 0 {
|
||||
// Varint21FrameDecoder: a frame with no bytes is a
|
||||
// corrupted frame, not an empty packet.
|
||||
return Err(Error::new("Frame length cannot be zero"));
|
||||
}
|
||||
let start = i + 1;
|
||||
if buffer.len() < start + len {
|
||||
return Ok(None);
|
||||
@@ -85,7 +90,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
|
||||
let mut r = Reader::new(&frame);
|
||||
let size = r.varint()?;
|
||||
if size == 0 {
|
||||
return Ok(frame[1..].to_vec());
|
||||
return Ok(frame[r.position()..].to_vec());
|
||||
}
|
||||
let size = usize::try_from(size).map_err(|_| Error::new("negative uncompressed length"))?;
|
||||
if size < threshold {
|
||||
@@ -98,7 +103,7 @@ pub fn decompress(frame: Vec<u8>, threshold: Option<usize>) -> Result<Vec<u8>> {
|
||||
"Badly compressed packet - size of {size} is larger than protocol maximum"
|
||||
)));
|
||||
}
|
||||
let start = varint_size(size as i32);
|
||||
let start = r.position();
|
||||
let mut out = Vec::with_capacity(size);
|
||||
ZlibDecoder::new(&frame[start..])
|
||||
.take(size as u64 + 1)
|
||||
@@ -163,4 +168,20 @@ mod tests {
|
||||
frame.extend(z.finish().unwrap());
|
||||
assert!(decompress(frame, Some(256)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_noncanonical_length_prefix_still_reads() {
|
||||
// The data-length VarInt is read, not measured: zero as `0x80 0x00`.
|
||||
assert_eq!(
|
||||
decompress(vec![0x80, 0x00, 7, 1, 2], Some(256)).unwrap(),
|
||||
[7, 1, 2]
|
||||
);
|
||||
// And a compressed packet whose size was written wide: 300.
|
||||
let body = vec![9; 300];
|
||||
let mut frame = vec![0xAC, 0x82, 0x00];
|
||||
let mut z = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
z.write_all(&body).unwrap();
|
||||
frame.extend(z.finish().unwrap());
|
||||
assert_eq!(decompress(frame, Some(256)).unwrap(), body);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ pub mod component {
|
||||
pub const ATTACK_RANGE: i32 = 30;
|
||||
pub const STORED_ENCHANTMENTS: i32 = 45;
|
||||
pub const PROFILE: i32 = 72;
|
||||
pub const BANNER_PATTERNS: i32 = 74;
|
||||
pub const BASE_COLOR: i32 = 75;
|
||||
pub const DYED_COLOR: i32 = 47;
|
||||
pub const MAP_ID: i32 = 48;
|
||||
pub const MAP_POST_PROCESSING: i32 = 50;
|
||||
@@ -112,6 +114,10 @@ pub enum Component {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
|
||||
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
|
||||
/// registry name: that value stays [`Component::Raw`].
|
||||
BannerPatterns(Vec<(i32, i32)>),
|
||||
/// A component Horizon reads past without interpreting it: its encoded
|
||||
/// bytes, written back as they came.
|
||||
Raw(Vec<u8>),
|
||||
@@ -349,6 +355,19 @@ impl Component {
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// DyeColor.STREAM_CODEC: its id.
|
||||
BASE_COLOR => Self::Int(r.varint()?),
|
||||
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
|
||||
// reference's id plus one, or 0 and the pattern itself) and a dye.
|
||||
BANNER_PATTERNS => {
|
||||
let start = r.position();
|
||||
skip::component(BANNER_PATTERNS, r)?;
|
||||
let bytes = r.since(start);
|
||||
match Self::banner_layers(bytes) {
|
||||
Some(layers) => Self::BannerPatterns(layers),
|
||||
None => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let start = r.position();
|
||||
skip::component(other, r)?;
|
||||
@@ -356,9 +375,32 @@ impl Component {
|
||||
}
|
||||
})
|
||||
}
|
||||
/// The layers of encoded `BannerPatternLayers` whose patterns are all
|
||||
/// registry references (`None` when one is direct).
|
||||
fn banner_layers(bytes: &[u8]) -> Option<Vec<(i32, i32)>> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let n = r.count(r.remaining()).ok()?;
|
||||
let mut layers = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let pattern = r.varint().ok()?;
|
||||
let color = r.varint().ok()?;
|
||||
if pattern < 1 {
|
||||
return None;
|
||||
}
|
||||
layers.push((pattern - 1, color));
|
||||
}
|
||||
(r.remaining() == 0).then_some(layers)
|
||||
}
|
||||
pub(crate) fn write(&self, kind: i32, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Raw(bytes) => w.bytes(bytes),
|
||||
Self::BannerPatterns(layers) => {
|
||||
w.varint(layers.len() as i32);
|
||||
for (pattern, color) in layers {
|
||||
w.varint(pattern + 1);
|
||||
w.varint(*color);
|
||||
}
|
||||
}
|
||||
Self::Trim { material, pattern } => {
|
||||
w.varint(material + 1);
|
||||
w.varint(pattern + 1);
|
||||
|
||||
@@ -13,7 +13,7 @@ const TYPE_SRV: u16 = 33;
|
||||
pub(super) const SERVER_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// JNDI reads them from resolv.conf on Unix and from the network adapters on
|
||||
/// Windows.
|
||||
/// Windows, where it leaves out the scoped IPv6 ones.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
std::fs::read_to_string("/etc/resolv.conf")
|
||||
@@ -27,7 +27,7 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp {
|
||||
continue;
|
||||
}
|
||||
for &ip in adapter.dns_servers() {
|
||||
for &ip in adapter.dns_servers().iter().filter(|ip| !scoped(**ip)) {
|
||||
let server = SocketAddr::new(ip, 53);
|
||||
if !servers.contains(&server) {
|
||||
servers.push(server);
|
||||
@@ -42,6 +42,13 @@ pub(super) fn system_name_servers() -> Vec<SocketAddr> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Link-local and site-local IPv6 addresses (such as Windows' `fec0:0:0:ffff::1`
|
||||
/// defaults), which only work with a scope the adapter's list doesn't carry.
|
||||
#[cfg(any(windows, test))]
|
||||
fn scoped(ip: std::net::IpAddr) -> bool {
|
||||
matches!(ip, std::net::IpAddr::V6(v6) if matches!(v6.segments()[0] & 0xffc0, 0xfe80 | 0xfec0))
|
||||
}
|
||||
|
||||
/// `nameserver` lines of resolv.conf, in order.
|
||||
#[cfg(any(unix, test))]
|
||||
fn name_servers(conf: &str) -> Vec<SocketAddr> {
|
||||
@@ -303,6 +310,17 @@ mod tests {
|
||||
assert_eq!(servers, ["192.168.1.1:53", "[fe80::1]:53", "[::1]:53"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_skips_scoped_ipv6_name_servers() {
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
for s in ["fe80::1", "fec0:0:0:ffff::1", "febf::1"] {
|
||||
assert!(scoped(ip(s)), "{s}");
|
||||
}
|
||||
for s in ["1.1.1.1", "2606:4700:4700::1111", "::1", "fc00::1"] {
|
||||
assert!(!scoped(ip(s)), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A local name server: a silent one first, then one that answers truncated
|
||||
/// over UDP and in full over TCP, like a real server with a big answer.
|
||||
#[test]
|
||||
|
||||
@@ -335,9 +335,35 @@ Make an instance: empty but for your options and name, or a copy of another one'
|
||||
|
||||
- `NAME`
|
||||
- `--copy`: The instance to copy (its name or id)
|
||||
- `--minecraft`: Make a Minecraft: Java Edition instance of this version (1.21.4, latest or latest-snapshot) instead of a Horizon one
|
||||
|
||||
MCP: `instance_create`.
|
||||
|
||||
## `horizon-launcher instance versions`
|
||||
|
||||
The versions of Minecraft: Java Edition a Java instance can play, the newest first.
|
||||
|
||||
horizon-launcher instance versions
|
||||
|
||||
- `--snapshots`: Snapshots too
|
||||
- `--old`: Old betas and alphas too
|
||||
|
||||
MCP: `instance_versions`.
|
||||
|
||||
## `horizon-launcher instance java`
|
||||
|
||||
A Java instance's Java: its own Java home or the automatic pick, memory and JVM arguments.
|
||||
|
||||
horizon-launcher instance java <INSTANCE>
|
||||
|
||||
- `INSTANCE`
|
||||
- `--home`: A Java home to run it on
|
||||
- `--auto`: Go back to the Java the version needs, picked or downloaded by the launcher
|
||||
- `--memory`: The most memory the game may use, in MiB
|
||||
- `--args`: Extra JVM arguments (one string; "" clears them)
|
||||
|
||||
MCP: `instance_java`.
|
||||
|
||||
## `horizon-launcher instance rename`
|
||||
|
||||
Rename an instance (its folder stays).
|
||||
@@ -578,6 +604,24 @@ Turn a pack off and move it to the system's trash.
|
||||
|
||||
MCP: `packs_remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher java list`
|
||||
|
||||
The Java runtimes Horizon downloaded from Mojang and the ones installed on this computer.
|
||||
|
||||
horizon-launcher java list
|
||||
|
||||
MCP: `java_list`.
|
||||
|
||||
## `horizon-launcher java install`
|
||||
|
||||
Download Mojang's Java runtime: a major (8, 16, 17, 21, 25) or a component name from a version's JSON (java-runtime-delta).
|
||||
|
||||
horizon-launcher java install <JAVA>
|
||||
|
||||
- `JAVA`
|
||||
|
||||
MCP: `java_install`.
|
||||
|
||||
## `horizon-launcher screenshots list`
|
||||
|
||||
The screenshots, the newest first, with their files (the default).
|
||||
|
||||
+20
-1
@@ -19,6 +19,19 @@ builds after its Nightly is replaced. All the mods of one build must pin the sam
|
||||
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
|
||||
pin builds on the commit of the installed version the build starts from.
|
||||
|
||||
### Mods on the hook points
|
||||
|
||||
A mod that hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`: empty
|
||||
functions the game calls at fixed places, plus helpers, kept stable across releases) can name the version it
|
||||
needs instead of pinning: `"hooks": 1` in its `.hnmod`'s `horizon-native.json`, the `NATIVE_HOOKS_API` of that
|
||||
file when it was made. Hook points are only ever added: each addition raises `NATIVE_HOOKS_API`, and a mod builds
|
||||
on any Horizon whose version is the one it needs or a later one. Its `horizon` is then only the commit it was tested on. The launcher builds it on the commit the other mods
|
||||
pin, else on the installed version's commit (the game the player plays), after checking that commit's
|
||||
`NATIVE_HOOKS_API` (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the
|
||||
mod was tested on. In its own copy of the mod it sets `horizon_revision` to the commit it builds on. Horizon's
|
||||
official mods (Zoom, FPS counter, Keystrokes, Minimap, Recipes) are made this way, so any of them build together
|
||||
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
|
||||
@@ -53,7 +66,10 @@ trusting native code is the player's decision, never an agent's.
|
||||
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
|
||||
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
|
||||
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance). That
|
||||
instance gets each mod's settings from the default home the first time it plays it (its
|
||||
`horizon-mods/.horizon-mod-data/<id>/` folder and `key.<id>.` lines of `horizon-mods.txt`, where the mod's
|
||||
WebAssembly version kept them); nothing it already has is overwritten.
|
||||
|
||||
## Cache and disk
|
||||
|
||||
@@ -93,6 +109,9 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
|
||||
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
|
||||
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
|
||||
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
|
||||
A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
|
||||
are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the
|
||||
player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
|
||||
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
|
||||
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
|
||||
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
|
||||
|
||||
+80
-109
@@ -2,8 +2,9 @@
|
||||
|
||||
How a change gets from a pull request to players: the checks a pull request passes, how it merges, and the
|
||||
automatic releases that follow. Releases are automatic and frequent so the latest Horizon is always playable.
|
||||
There is one channel ([One channel](#one-channel)): each release is published as the release and GitHub's *latest*
|
||||
once its smoke test passed and no `release-blocker` is open, and launchers and servers install it at once.
|
||||
Since 2026-10-28 Horizon ships only the **Nightly** (Ludovic's decision; the Team proposed it): the batch train builds
|
||||
a Nightly instead of a `vX.Y.Z` release, it is published once its smoke test passed and no `release-blocker` is open,
|
||||
and every launcher and `horizon-server update` install it ([Nightly only](#nightly-only)).
|
||||
|
||||
## Pull request checks
|
||||
|
||||
@@ -177,9 +178,9 @@ which runs them on main a batch of merges at a time, on the Mac mini's runner:
|
||||
the failing harnesses, names the first merge they fail on in the run's summary and comments on its pull request
|
||||
(`🤖 Agent: parity-main found that this merge breaks ...`). Fix it with a pull request as usual; the next
|
||||
run checks from the last green commit again.
|
||||
- Game releases ship only a commit both `checks` and `parity-main` passed on (`tools/release_train.py`, see
|
||||
below), so a broken merge holds releases instead of reaching players. `release.yml` also starts when a
|
||||
parity-main run finishes. A forced release (`force=true`) doesn't wait for parity-main.
|
||||
- Nightlies build only on a commit both `checks` and `parity-main` passed on (`tools/release_train.py`,
|
||||
`tools/ci/nightly.py`, see below), so a broken merge holds them instead of reaching players. `release.yml` also
|
||||
starts when a parity-main run finishes. A forced run (`force=true`) doesn't wait for parity-main.
|
||||
|
||||
A pull request still runs its own parity job when it changes a harness script, an oracle
|
||||
(`mods/horizon/src/test/`), a probe (`crates/*/examples/`, `game/*_fixture.rs`, `game/*_harness.rs`), the runner
|
||||
@@ -188,73 +189,80 @@ A pull request still runs its own parity job when it changes a harness script, a
|
||||
|
||||
## Game releases
|
||||
|
||||
`.github/workflows/release.yml` releases the game by itself. It starts after every green `checks` or `parity-main`
|
||||
run of a push to main (it releases a commit only once both passed on it), every 10 minutes (so a release waiting for merges to settle goes out without another merge), and by hand.
|
||||
`.github/workflows/release.yml` is the batch train: it decides when a new Nightly goes out and starts
|
||||
`nightly build` (`.github/workflows/nightly-build.yml`, [Nightly channel](#nightly-channel)) on the commit it
|
||||
decided, which builds, signs and publishes it. It starts after every green `checks` or `parity-main` run of a push to
|
||||
main, every 10 minutes (so a batch waiting for merges to settle goes out without another merge), and by hand.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pr(["pull request"]) --> checks{"verify<br/>parity"}
|
||||
checks -- green --> automerge["auto-merge<br/>(merge commit)"]
|
||||
automerge --> main(["main"])
|
||||
main --> mainchecks{"checks on main"}
|
||||
checks -- green --> merge["merge<br/>(merge commit)"]
|
||||
merge --> main(["main"])
|
||||
main --> mainchecks{"checks and<br/>parity-main"}
|
||||
mainchecks -- green --> decide{"decide<br/>tools/release_train.py"}
|
||||
cron(["every hour :17"]) --> decide
|
||||
decide -- "player-facing PRs settled 5 min<br/>(or waiting 20 min), or release-now" --> build
|
||||
cron(["every 10 minutes"]) --> decide
|
||||
decide -- "5 player-facing PRs settled 5 min,<br/>the oldest waited 6 h, or release-now" --> nightly["nightly build:<br/>that commit + the reviewers' picks"]
|
||||
decide -- otherwise --> wait(["wait"])
|
||||
subgraph build["build, in parallel"]
|
||||
linux["Linux x86_64<br/>GitHub ubuntu-22.04"]
|
||||
windows["Windows x64<br/>GitHub windows-latest"]
|
||||
mac["macOS arm64<br/>Mac mini runner<br/>+ offscreen game run"]
|
||||
end
|
||||
build --> gate{"gate: the smoke test drew a world,<br/>no release-blocker is open"}
|
||||
nightly --> gate{"smoke test drew a world,<br/>no release-blocker is open"}
|
||||
gate -- no --> nothing(["nothing is published"])
|
||||
gate -- yes --> publish["publish vX.Y.Z as the release and latest<br/>notes from the merged PRs"]
|
||||
gate -- yes --> publish["publish the nightly pre-release<br/>notes: what merged since the last one"]
|
||||
publish --> launcher(["every launcher and server installs it"])
|
||||
publish --> kira(["Kira posts it; the first release<br/>after 20:00 UTC pings, once a day"])
|
||||
publish --> kira(["Kira posts it; the first after<br/>20:00 UTC pings, once a day"])
|
||||
```
|
||||
|
||||
### When it releases
|
||||
|
||||
`tools/release_train.py decide --kind game` looks at the pull requests merged into main (first-parent merge
|
||||
commits) since the last **published** game release:
|
||||
commits) since the commit the published Nightly was built on:
|
||||
|
||||
- A pull request counts when it changed what players run: `crates/` (not `examples/`, `tests/`, `benches/`),
|
||||
`xtask/`, `packaging/`, `Cargo.toml`, `Cargo.lock`, `research-lock.json`, or an exporter `horizon-prepare`
|
||||
compiles (the `mods/horizon/src/test/java/dev/horizonlab/parity/*.java` files its sources name). Markdown never
|
||||
counts.
|
||||
- It releases in batches, so players get a few releases a day rather than one per merge: once 5 pull requests count
|
||||
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one release), or once the oldest has
|
||||
- It goes in batches, so players get a few Nightlies a day rather than one per merge: once 5 pull requests count
|
||||
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one Nightly), or once the oldest has
|
||||
waited 6 hours, however few they are.
|
||||
- A pull request labelled `release-now` (a crash or a blocker) releases at once.
|
||||
- A pull request labelled `release-now` (a crash or a blocker) goes out at once.
|
||||
- A pull request labelled `skip-release` (work players don't see) doesn't count and isn't listed in the notes.
|
||||
- An open issue labelled `release-blocker` holds every game release, forced ones too (`held by release-blocker #N`).
|
||||
- One release at a time (concurrency group `game-release`); a run that finds nothing new does nothing.
|
||||
- A scheduled or hand-started run releases main's newest commit whose `checks` passed; after `checks`, the commit
|
||||
they passed on.
|
||||
- The first release of a new release line (below) goes out at once, without waiting for a pull request.
|
||||
- An open issue labelled `release-blocker` holds every Nightly, forced ones too (`held by release-blocker #N`);
|
||||
`nightly build` checks again right before publishing.
|
||||
- One Nightly build at a time: while one runs or waits, the train starts none. At most 12 builds a UTC day (the
|
||||
reviewers' `/nightly` builds count): past that the train waits for the next day, except for `release-now` and
|
||||
`force=true`.
|
||||
- A scheduled run takes main's newest commit where `checks` and `parity-main` passed; after a run of either, the
|
||||
commit it passed on, once both passed there.
|
||||
|
||||
### Tags
|
||||
### Nightly only
|
||||
|
||||
How releases are numbered, and how installs from before 0.2 reach it: [versioning.md](versioning.md).
|
||||
Ludovic decided on 2026-10-28 that Horizon ships only the Nightly. No `vX.Y.Z` game release is made any more:
|
||||
the last one stays GitHub's *latest*, so launchers and servers from before the switch (which don't read Nightlies)
|
||||
keep a working build until they update themselves. `.github/release-line.txt` still numbers the launcher's releases
|
||||
(`[email protected]`).
|
||||
|
||||
- With a release line in `.github/release-line.txt` (`MAJOR.MINOR`, e.g. `0.2`), a game release is `vMAJOR.MINOR.PATCH`,
|
||||
the patch after the newest release tagged on that line (`v0.2.0` first). A `feature` label doesn't change the line:
|
||||
only that file does, by pull request.
|
||||
- Without the file (every release before 0.2), `vYYYY.MM.DD`, then `.01`, `.02`… the same UTC day.
|
||||
- Every reader (`release_train.py`, the launcher, `horizon-server update`, the website) ranks
|
||||
every `vX.Y.Z` tag above every dated one.
|
||||
- **Launchers** (#3342) install the current `nightly` pre-release as their update, as the version
|
||||
`nightly-<date>-<commit>`, after checking its checksums signed for the Nightly channel. A player on the newest
|
||||
release, on a Nightly or on nothing moves to it at the next check; a version picked by hand or pinned by an
|
||||
instance stays. Worlds are in the game's folder, never in a version's, so nothing touches them. The "Nightly
|
||||
builds" setting is gone. The last week's Nightlies (`nightly-<date>`) and the old releases stay on the Versions
|
||||
page.
|
||||
- **Servers** (#3331): `horizon-server update`, `install.sh` and `install.ps1` follow the Nightly the same way.
|
||||
- **Kira** posts each Nightly in #releases; the bug template asks for the version the game shows.
|
||||
- **Everyone gets the reviewers' picks.** A Nightly is main plus the pull requests the trusted reviewers picked
|
||||
([below](#trusted-reviewers-pick-the-nightlys-pull-requests)), so a pick reaches every player with the next
|
||||
Nightly. `/nightly clear` makes it main alone.
|
||||
|
||||
### Labels
|
||||
|
||||
| Label | Effect |
|
||||
| --- | --- |
|
||||
| `feature` | Listed under **New** (Kira pings the releases role); makes the next launcher release a minor one |
|
||||
| `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle |
|
||||
| `skip-release` | Doesn't trigger a release and isn't listed in the notes |
|
||||
| `feature` | Listed under **New** in the Nightly's and the launcher's notes |
|
||||
| `release-now` | A Nightly as soon as main's checks and parity-main pass, past the batch rule and the day's cap |
|
||||
| `skip-release` | Doesn't trigger a Nightly or a launcher release and isn't listed in the notes |
|
||||
| `run-parity` | Runs the parity job once on a fork's pull request |
|
||||
| `full-parity` | Runs the pull request's parity job on playhorizon's runner only (a change every harness reaches) |
|
||||
| `parity-regression` | Opened by the nightly audit for a harness that fails on main |
|
||||
| `release-blocker` | On an issue: no game release or Nightly is published while it is open ([One channel](#one-channel)) |
|
||||
| `release-blocker` | On an issue: no Nightly is published while it is open ([When it releases](#when-it-releases)) |
|
||||
|
||||
Pull requests without `feature` are listed under **Fixed**. Titles are written for players, since they become
|
||||
the notes.
|
||||
@@ -275,51 +283,23 @@ runs the game: GitHub's runners have neither a GPU worth the name nor the game f
|
||||
|
||||
### Notes
|
||||
|
||||
`tools/release_train.py notes` writes them from the same pull requests:
|
||||
`tools/ci/nightly.py pick` writes them: what reached main since the previous Nightly (`### New`, label `feature`,
|
||||
and `### Fixed`, one line per pull request: its title, its number, the issues it closes; `release_train.bullet`),
|
||||
then the picked pull requests (`### Not merged yet`), `### Known issues` (the bullets of
|
||||
[docs/known-issues.md](known-issues.md)), thanks to outside contributors, the source commit and the disclaimer. The
|
||||
title stays `Nightly <date> · <commit>`, which launchers and servers read the version from. Pull request titles
|
||||
are written for players, since they become the notes.
|
||||
|
||||
- the title, `Horizon 0.2.3: <the first pull request titles>` (before 0.2, `Horizon 2026.10.15.01: …`, a day's later releases counting .01, .02…, two digits so GitHub's releases page, which sorts them as text, kept them in order), features first, newest first;
|
||||
- a line's first release (`X.Y.0`) opens with a paragraph that says so; 0.2.0's says releases are numbered from now
|
||||
on and how a server that says it is up to date takes it;
|
||||
- `### New` (label `feature`) and `### Fixed` (the rest), one line per pull request: its title, its number, the
|
||||
issues it closes;
|
||||
- `### Known issues`: the bullets of [docs/known-issues.md](known-issues.md);
|
||||
- thanks to outside contributors, how to install, the disclaimer.
|
||||
|
||||
Kira posts every release once in Discord's #releases. It pings the releases role at most once a day: the first game
|
||||
release published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
|
||||
Kira posts every Nightly once in Discord's #releases. It pings the releases role at most once a day: the first
|
||||
Nightly published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
|
||||
silent (no ping and Discord's @silent flag). Launcher releases post without a ping.
|
||||
|
||||
### One channel
|
||||
|
||||
Since 2026-10-23 there is one release channel, and it is every launcher's default. Since 2026-10-27 (Ludovic's
|
||||
decision, revising 2026-10-23's) players may also turn on [Nightly](#nightly-channel), an opt-in pre-release of main
|
||||
plus pull requests the trusted reviewers picked, not all reviewed yet; nothing about releases changes for anyone who
|
||||
doesn't. Every game release is published
|
||||
as the release, never a pre-release, and marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing
|
||||
promotes it later, the checks that used to gate a promotion run in the release itself, and the publish job
|
||||
(`tools/release_train.py gate`) publishes nothing unless both pass:
|
||||
|
||||
- the release smoke test: the macOS build runs the game offscreen on the Mac mini and keeps its report,
|
||||
`smoke-test.json`, only when the game drew a world. The gate reads the report again before publishing, and the
|
||||
release carries it. A run whose macOS build went to GitHub (`MAC_RUNNER=macos-latest`) has no report, so it
|
||||
publishes nothing;
|
||||
- no open issue labelled `release-blocker`. `decide` checks first, so a blocked train builds nothing; the gate checks
|
||||
again right before publishing, so an issue labelled while the builds ran still holds the release. Only the label
|
||||
and the issue's state count, never its text: whoever opened the issue can edit the text after a maintainer
|
||||
labels it. Close the issue (or remove the label) and the next run releases everything waiting, with notes
|
||||
covering all of it.
|
||||
|
||||
A failed gate fails the run; the next run (10 minutes later) decides again. Nothing older than the latest release is
|
||||
ever published: tags only count up.
|
||||
|
||||
The launcher has no channel setting any more and looks for releases every 30 minutes. The game pre-releases of the
|
||||
old Alpha channel (v0.2.1 on, published before this change) stay listed for anyone who wants one by hand, but no
|
||||
launcher installs them as an update, and `release_train.py` ignores them: the first release of the single channel
|
||||
lists everything since the last release that was not a pre-release (v0.2.0). Launchers from before this change keep
|
||||
updating either way: with Alpha (or 0.17's "Beta builds") on they take every release, pre-release or not; with it
|
||||
off, every release that is not a pre-release, which is now every new one.
|
||||
|
||||
`promote.yml` and `tools/release_channels.py` (the daily promotion at 20:00 UTC) are gone.
|
||||
Before 2026-10-28 game releases were `vX.Y.Z` (numbering: [versioning.md](versioning.md)), each published by this
|
||||
workflow as the release and GitHub's *latest*. From 2026-10-23 there was one channel (the Alpha pre-releases and the
|
||||
daily promotion at 20:00 UTC ended: `promote.yml` and `tools/release_channels.py` are gone), and from 2026-10-27 the
|
||||
Nightly was opt-in beside it. Since 2026-10-28 the one channel is the Nightly ([Nightly only](#nightly-only)).
|
||||
|
||||
### Signed checksums
|
||||
|
||||
@@ -366,39 +346,29 @@ them with a clear message, and players download the launcher again from the webs
|
||||
### Starting one by hand
|
||||
|
||||
```sh
|
||||
gh workflow run release --ref <branch> # dry run: builds everything, writes the notes, publishes nothing
|
||||
gh workflow run release --ref <branch> # dry run: decides and says why, starts nothing
|
||||
gh workflow run release --ref main -f dry_run=false # the usual decision, now
|
||||
gh workflow run release --ref main -f dry_run=false -f force=true # release now, whatever the rules (even while paused)
|
||||
gh workflow run release --ref main -f dry_run=false -f force=true # a Nightly now, whatever the rules (even while paused)
|
||||
```
|
||||
|
||||
A hand-started run on any branch but main is always a dry run. The notes are in the run's summary and its
|
||||
`release-notes` artifact.
|
||||
|
||||
### Pausing
|
||||
|
||||
```sh
|
||||
gh variable set RELEASES_PAUSED --body 1 # no automatic game or launcher releases
|
||||
gh variable delete RELEASES_PAUSED # back on; the next hourly run catches up
|
||||
gh variable set RELEASES_PAUSED --body 1 # no automatic Nightly or launcher releases
|
||||
gh variable delete RELEASES_PAUSED # back on; the next run catches up
|
||||
```
|
||||
|
||||
While paused, runs still decide and say why in their summary; `force=true` still releases, but never past an open
|
||||
`release-blocker`.
|
||||
While paused, runs still decide and say why in their summary; `force=true` still starts a Nightly, but never past an
|
||||
open `release-blocker`. The reviewers' `/nightly` and the 20:17 UTC build still run: a line `paused` in
|
||||
`.github/trusted-reviewers.txt` stops the reviewers' commands.
|
||||
|
||||
### Rolling back
|
||||
|
||||
A bad release is withdrawn by turning it back into a draft:
|
||||
|
||||
```sh
|
||||
gh release edit v0.2.3 --draft
|
||||
```
|
||||
|
||||
To stop the next releases while a problem is fixed, label an issue about it `release-blocker`. For the bad
|
||||
release itself: launchers list only published releases, so the previous version becomes the newest one again, launchers that
|
||||
didn't install the bad one won't, and players who did can pick the previous version in the launcher's version
|
||||
list (`launcher/src/backend.rs` tests `withdrawn_release_is_never_offered`). The tag stays, so the next release
|
||||
that day takes the next number, and its notes start from the last *published* release, so they list the
|
||||
withdrawn release's changes again with the fix. Usually the better way out is forward: revert the pull request,
|
||||
label the revert `release-now`, and a fixed release is out as soon as main's checks pass.
|
||||
To stop the next Nightlies while a problem is fixed, label an issue about it `release-blocker`. The way out is
|
||||
forward: revert the pull request (or `/nightly remove` a pick), label the revert `release-now`, and a fixed Nightly
|
||||
is out as soon as main's checks and parity-main pass. Meanwhile a player can pick an earlier build on the launcher's
|
||||
Versions page (the last week's `nightly-<date>`, or a `vX.Y.Z`), and `horizon-server update --version <tag>` does
|
||||
the same for a server.
|
||||
|
||||
## Launcher releases
|
||||
|
||||
@@ -441,8 +411,9 @@ launcher and the website read: both then look the newest launcher tags up one by
|
||||
|
||||
## Nightly channel
|
||||
|
||||
`.github/workflows/nightly-build.yml` publishes an opt-in build once a night (20:17 UTC) and when a trusted
|
||||
reviewer asks, for players who want fixes before they are released (#2681). It builds main's newest commit where
|
||||
`.github/workflows/nightly-build.yml` builds and publishes the Nightly, the only build players get since
|
||||
2026-10-28: when the batch train decides ([Game releases](#game-releases), with the commit as `base`), once a night
|
||||
(20:17 UTC) and when a trusted reviewer asks (#2681). It builds main's newest commit where
|
||||
`checks` and `parity-main` passed, as a release does (or the `base` input), plus the pull requests the trusted
|
||||
reviewers picked, merged in their order; with none picked, that commit alone (`tools/ci/nightly.py pick`). No green
|
||||
commit (main's newest `checks` or `parity-main` run failed) builds nothing. A night with nothing new publishes nothing. Since the Nightly is the version every player installs, a pull request goes in only with an
|
||||
@@ -523,7 +494,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
- A one-line pull request removing a login from `.github/trusted-reviewers.txt` revokes it as soon as it merges
|
||||
(the file is his under CODEOWNERS).
|
||||
- A line `paused` in that file stops every reviewer's `/nightly` (and triage commands); his own still work.
|
||||
- Main, the gate and releases don't change: picks reach only players who turned Nightly on.
|
||||
- Main and the gate don't change, but a pick reaches every player with the next Nightly.
|
||||
|
||||
- **Jobs.** `pick` assembles the commit and writes the notes: what reached main since the previous Nightly (New and
|
||||
Fixed, as a release's notes), then each picked pull request with its title, author and who picked it, the known
|
||||
@@ -546,9 +517,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
macOS jobs on the runner that runs pull requests' parity jobs (`horizon-game`), never the release runner
|
||||
(`MAC_RUNNER`), and its Rust cache has its own key and is never saved. A trusted build doesn't use the machine's
|
||||
shared sccache, which pull requests' jobs write (`HORIZON_NO_SCCACHE`, `tools/ci/runner-env.sh`).
|
||||
- **Players.** Releases stay the default and the only channel any launcher follows unless its player turns Nightly
|
||||
on. `release_train.py`, `horizon-server update`, `install.sh` and the launcher's release list skip the tags: none
|
||||
of them is `vX.Y.Z`.
|
||||
- **Players.** Every launcher and `horizon-server update` install the `nightly` pre-release
|
||||
([Nightly only](#nightly-only)). It is never GitHub's latest: the last `vX.Y.Z` stays that, for launchers,
|
||||
servers and `install.sh` from before the switch.
|
||||
|
||||
```sh
|
||||
gh workflow run "nightly build" # now; skips when the pick is the commit already published
|
||||
|
||||
@@ -7,6 +7,11 @@ Until 0.2 the game was tagged by date (`v2026.10.19.15`) and the launcher counte
|
||||
From 0.2 both follow [semantic versioning](https://semver.org) and share one release line: **Horizon 0.2.x** is the
|
||||
game and the launcher of the 0.2 line.
|
||||
|
||||
**Since 2026-10-28 the game ships only Nightlies** ([release-flow.md](release-flow.md#nightly-only)): no new `vX.Y.Z`
|
||||
game tag is made, and a game build is named `nightly-<date>-<commit>` (its release title, "Nightly <date> ·
|
||||
<commit>"), which launchers and servers rank after every `vX.Y.Z`. The launcher keeps its `[email protected]` tags on
|
||||
the release line. What follows describes the `vX.Y.Z` game tags as they were.
|
||||
|
||||
## Tags
|
||||
|
||||
| | Game | Launcher |
|
||||
|
||||
@@ -197,6 +197,12 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
|
||||
**Delete** (to the trash, after a confirmation). From the command line:
|
||||
`horizon-launcher screenshots [list|delete] [--instance <name>]`.
|
||||
- **Java** (for the Minecraft: Java Edition instances to come): the runtime a version's JSON asks for
|
||||
(`javaVersion`: its major exactly, Java 8 before that field), found among Mojang's runtimes Horizon
|
||||
downloaded (`minecraft/runtime/` in its data folder, the one preparing uses) and the Java homes installed
|
||||
on this computer, read from their `release` files without starting them; when none has that major,
|
||||
Mojang's runtime is downloaded; an instance may name its own Java home instead.
|
||||
`horizon-launcher java [list|install]`.
|
||||
- **Multiplayer**: Home lists the selected instance's servers, the game's own Multiplayer list
|
||||
(`servers.dat`, each with the picture the game last saw). **Join** starts the game straight into
|
||||
one; **Add server** and **Remove** (after a confirmation) change the file the game reads,
|
||||
@@ -307,6 +313,9 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
|
||||
horizon-launcher account offline play without an account
|
||||
horizon-launcher instance [list] the instances and their game folders (* = Play starts it)
|
||||
horizon-launcher instance create <name> [--copy <instance>] empty, or a copy of one's worlds, options and packs
|
||||
horizon-launcher instance create <name> --minecraft <1.21.4|latest|latest-snapshot> a Minecraft: Java Edition instance
|
||||
horizon-launcher instance versions [--snapshots] [--old] the Java Edition versions, from Mojang's list (kept for offline)
|
||||
horizon-launcher instance java <instance> [--home <dir>|--auto] [--memory <MiB>] [--args "<jvm args>"]
|
||||
horizon-launcher instance rename|icon|group <instance> <value> icon: a block or item name, #rrggbb or none
|
||||
horizon-launcher instance select <instance> make Play start it
|
||||
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
|
||||
@@ -316,6 +325,8 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
|
||||
horizon-launcher packs add <file> | on|off|remove <pack> [--kind shader]
|
||||
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
|
||||
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
|
||||
horizon-launcher java [list] Java runtimes: Mojang's (downloaded by Horizon) and installed ones
|
||||
horizon-launcher java install <21|java-runtime-delta> downloads Mojang's runtime, each file sha1-checked
|
||||
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
|
||||
horizon-launcher multiplayer add <address> [--name <name>] | remove <server>
|
||||
horizon-launcher modpack [list] the imported packs and their coverage
|
||||
|
||||
@@ -154,7 +154,11 @@ fn serve(mock: Arc<Mutex<Mock>>) -> Endpoints {
|
||||
let body = String::from_utf8(body).unwrap();
|
||||
let (status, text) = {
|
||||
let mut m = mock.lock().unwrap();
|
||||
m.log.push((method.clone(), path.clone(), body.clone()));
|
||||
// A bare `GET /` is a local tool probing every new port (T3 Code does, on the Mac
|
||||
// mini), not the launcher: no endpoint here is the root.
|
||||
if (method.as_str(), path.as_str()) != ("GET", "/") {
|
||||
m.log.push((method.clone(), path.clone(), body.clone()));
|
||||
}
|
||||
route(&mut m, &method, &path, &auth, &body)
|
||||
};
|
||||
let _ = write!(
|
||||
|
||||
+24
-1
@@ -1264,6 +1264,29 @@ fn download_for_owner(
|
||||
download_for(pins, dir, pass, !accounts.accounts.is_empty(), fetch, log)
|
||||
}
|
||||
|
||||
/// Downloads Minecraft `id` (any version on Mojang's list, its version file's sha1 from it) into
|
||||
/// Horizon's own game folder, in the official launcher's layout, for an account that owns the
|
||||
/// game (asked again now). Returns that folder.
|
||||
pub fn download_version_for_owner(
|
||||
id: &str,
|
||||
manifest_sha1: &str,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<PathBuf, String> {
|
||||
let dir = data_dir().join("minecraft");
|
||||
let fetch = |url: &str| -> Result<Vec<u8>, String> {
|
||||
let mut body = Vec::new();
|
||||
get(url)?
|
||||
.into_reader()
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(body)
|
||||
};
|
||||
let pins = sources::Pins::for_version(id, manifest_sha1);
|
||||
download_for_owner(&pins, &dir, &fetch, log)
|
||||
.map_err(|e| e.trim_start_matches("\n • ").to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// What [`download_for_owner`] does with the owner gate's answer: Mojang's files only for its
|
||||
/// `owner`, an account the services said owns the game just now.
|
||||
fn download_for(
|
||||
@@ -1369,7 +1392,7 @@ fn java(
|
||||
}
|
||||
|
||||
/// The dynamic loader glibc programs on x86-64 Linux start through.
|
||||
const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
|
||||
pub(crate) const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
|
||||
|
||||
/// Runs `cmd` with Java at `home`: JAVA_HOME names it and its `bin` leads the PATH, as the
|
||||
/// versions' own preparers look for Java.
|
||||
|
||||
@@ -646,7 +646,10 @@ fn install_version(
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
fs::rename(&path, &target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
if let Some(old) = old.as_ref().filter(|o| o.file != relative) {
|
||||
if let Some(old) = old
|
||||
.as_ref()
|
||||
.filter(|o| o.file != relative && !stays(native, &o.file))
|
||||
{
|
||||
let _ = fs::remove_file(home.join(&old.file));
|
||||
}
|
||||
if project.kind == Kind::Mod {
|
||||
@@ -674,6 +677,13 @@ fn install_version(
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Whether an update to a native version keeps the old file `old`: a mod that turned native
|
||||
/// keeps its `.hmod` until the player builds the game with the new version. The game runs it
|
||||
/// meanwhile and skips it once the native one is built in; removing the mod removes both.
|
||||
fn stays(native: bool, old: &str) -> bool {
|
||||
native && old.ends_with(".hmod")
|
||||
}
|
||||
|
||||
/// The mod id of an installed mod (`horizon-mods/<id>`, `horizon-mods/<id>.hmod` or
|
||||
/// `horizon-native-mods/<id>.hnmod`).
|
||||
pub fn mod_id(entry: &installed::Entry) -> Option<&str> {
|
||||
|
||||
@@ -141,6 +141,26 @@ fn installs_checked_turns_on_and_removes() {
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_that_turned_native_keeps_its_hmod_until_removed() {
|
||||
assert!(stays(true, "horizon-mods/coords_hud.hmod"));
|
||||
assert!(!stays(false, "horizon-mods/coords_hud.hmod"));
|
||||
assert!(!stays(true, "horizon-native-mods/coords_hud.hnmod"));
|
||||
let index = sample();
|
||||
let home = scratch("turned-native");
|
||||
install_from(&index, true, None, &hud(&index), &home, &|_, _| {}).unwrap();
|
||||
// As install_version leaves it after the native update.
|
||||
let mut list = installed::Installed::load(&home);
|
||||
list.entries[0].file = "horizon-native-mods/coords_hud.hnmod".into();
|
||||
list.save(&home).unwrap();
|
||||
fs::create_dir_all(home.join("horizon-native-mods")).unwrap();
|
||||
fs::write(home.join("horizon-native-mods/coords_hud.hnmod"), "").unwrap();
|
||||
installed::remove(&home, "coords_hud").unwrap();
|
||||
assert!(!home.join("horizon-native-mods/coords_hud.hnmod").exists());
|
||||
assert!(!home.join("horizon-mods/coords_hud.hmod").exists());
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_a_shader_pack_into_shaderpacks() {
|
||||
let index = sample();
|
||||
|
||||
@@ -261,6 +261,10 @@ pub fn remove(home: &Path, name: &str) -> Result<Entry, String> {
|
||||
}
|
||||
}
|
||||
if let Some(id) = super::horizon::mod_id(&entry).filter(|_| entry.source == SourceId::Horizon) {
|
||||
// The `.hmod` a mod that turned native left behind (horizon::install_version).
|
||||
if entry.file.ends_with(".hnmod") {
|
||||
let _ = fs::remove_file(home.join(super::java::MODS_DIR).join(format!("{id}.hmod")));
|
||||
}
|
||||
super::java::set_enabled(home, id, false)?;
|
||||
}
|
||||
installed
|
||||
|
||||
+136
-7
@@ -23,6 +23,15 @@ struct Cli {
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum JavaCmd {
|
||||
/// The Java runtimes Horizon downloaded from Mojang and the ones installed on this computer
|
||||
List,
|
||||
/// Download Mojang's Java runtime: a major (8, 16, 17, 21, 25) or a component name from a
|
||||
/// version's JSON (java-runtime-delta)
|
||||
Install { java: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum RustCmd {
|
||||
/// Whether it is installed, its version and whether this computer can link with it
|
||||
@@ -202,6 +211,35 @@ enum InstanceCmd {
|
||||
/// The instance to copy (its name or id)
|
||||
#[arg(long)]
|
||||
copy: Option<String>,
|
||||
/// Make a Minecraft: Java Edition instance of this version (1.21.4, latest or
|
||||
/// latest-snapshot) instead of a Horizon one
|
||||
#[arg(long, conflicts_with = "copy")]
|
||||
minecraft: Option<String>,
|
||||
},
|
||||
/// The versions of Minecraft: Java Edition a Java instance can play, the newest first
|
||||
Versions {
|
||||
/// Snapshots too
|
||||
#[arg(long)]
|
||||
snapshots: bool,
|
||||
/// Old betas and alphas too
|
||||
#[arg(long)]
|
||||
old: bool,
|
||||
},
|
||||
/// A Java instance's Java: its own Java home or the automatic pick, memory and JVM arguments
|
||||
Java {
|
||||
instance: String,
|
||||
/// A Java home to run it on
|
||||
#[arg(long, conflicts_with = "auto")]
|
||||
home: Option<PathBuf>,
|
||||
/// Go back to the Java the version needs, picked or downloaded by the launcher
|
||||
#[arg(long)]
|
||||
auto: bool,
|
||||
/// The most memory the game may use, in MiB
|
||||
#[arg(long)]
|
||||
memory: Option<u32>,
|
||||
/// Extra JVM arguments (one string; "" clears them)
|
||||
#[arg(long, allow_hyphen_values = true)]
|
||||
args: Option<String>,
|
||||
},
|
||||
/// Rename an instance (its folder stays)
|
||||
Rename { instance: String, name: String },
|
||||
@@ -710,6 +748,11 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
action: Option<PacksCmd>,
|
||||
},
|
||||
/// Java for Minecraft: Java Edition instances: list the runtimes, download Mojang's
|
||||
Java {
|
||||
#[command(subcommand)]
|
||||
action: Option<JavaCmd>,
|
||||
},
|
||||
/// The screenshots F2 took in an instance: list, delete
|
||||
Screenshots {
|
||||
#[command(subcommand)]
|
||||
@@ -1327,11 +1370,49 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List { instance: None }), as_json)?,
|
||||
Cmd::Servers { action } => servers(action.unwrap_or(ServersCmd::List), as_json)?,
|
||||
Cmd::Rust { action } => rust(action.unwrap_or(RustCmd::Status), as_json)?,
|
||||
Cmd::Java { action } => java(action.unwrap_or(JavaCmd::List), as_json)?,
|
||||
Cmd::Mcp => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn java(action: JavaCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::java;
|
||||
match action {
|
||||
JavaCmd::List => {
|
||||
let mut all = java::mojang_runtimes(&java::runtimes_dir());
|
||||
all.extend(java::installed());
|
||||
if as_json {
|
||||
json(serde_json::json!(all));
|
||||
} else if all.is_empty() {
|
||||
println!("No Java yet. Horizon downloads Mojang's when an instance needs it.");
|
||||
} else {
|
||||
for r in &all {
|
||||
let from = match r.source {
|
||||
java::Source::Mojang => "Mojang's, downloaded by Horizon",
|
||||
java::Source::Installed => "installed",
|
||||
};
|
||||
println!("Java {} ({from})\n {}", r.version, r.home.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
JavaCmd::Install { java: which } => {
|
||||
let need = java::Need::named(&which)?;
|
||||
let runtime = java::install(&need, &|line| eprintln!("{line}"))?;
|
||||
if as_json {
|
||||
json(serde_json::json!(runtime));
|
||||
} else {
|
||||
println!(
|
||||
"Java {} is ready: {}",
|
||||
runtime.version,
|
||||
runtime.home.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rust(action: RustCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::rust_toolchain as rust;
|
||||
match action {
|
||||
@@ -2082,7 +2163,7 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
serde_json::json!({
|
||||
"id": i.id, "name": i.name, "kind": format!("{:?}", i.kind).to_lowercase(),
|
||||
"home": i.home, "icon": i.icon, "group": i.group, "selected": selected,
|
||||
"version": i.version, "played_secs": i.played.as_secs(),
|
||||
"version": i.version, "java": i.java, "played_secs": i.played.as_secs(),
|
||||
"last_played_ms": i.last_played.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
|
||||
})
|
||||
};
|
||||
@@ -2093,11 +2174,11 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
.as_ref()
|
||||
.map(|g| format!(" [{g}]"))
|
||||
.unwrap_or_default();
|
||||
let pinned = i
|
||||
.version
|
||||
.as_ref()
|
||||
.map(|v| format!(" {v}"))
|
||||
.unwrap_or_default();
|
||||
let pinned = match (&i.java, &i.version) {
|
||||
(Some(java), _) => format!(" Minecraft {} (Java Edition)", java.minecraft),
|
||||
(None, Some(v)) => format!(" {v}"),
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
let played = match i.last_played {
|
||||
Some(_) => format!(" {} played", instances::play_time(i.played)),
|
||||
None => String::new(),
|
||||
@@ -2126,7 +2207,55 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
all.iter().for_each(|i| line(i, i.id == selected));
|
||||
}
|
||||
}
|
||||
InstanceCmd::Create { name, copy } => changed(instances::create(&name, copy.as_deref())?),
|
||||
InstanceCmd::Create {
|
||||
name,
|
||||
copy,
|
||||
minecraft: None,
|
||||
} => changed(instances::create(&name, copy.as_deref())?),
|
||||
InstanceCmd::Create {
|
||||
name,
|
||||
minecraft: Some(version),
|
||||
..
|
||||
} => {
|
||||
let versions = horizon_launcher::minecraft_versions::load()?;
|
||||
changed(instances::create_java(&name, &versions.find(&version)?.id)?)
|
||||
}
|
||||
InstanceCmd::Versions { snapshots, old } => {
|
||||
let versions = horizon_launcher::minecraft_versions::load()?;
|
||||
let shown: Vec<_> = versions.of(snapshots, old).collect();
|
||||
if as_json {
|
||||
json(serde_json::json!(shown));
|
||||
} else {
|
||||
for v in shown {
|
||||
let date = v.released.get(..10).unwrap_or("");
|
||||
println!("{} {date} {:?}", v.id, v.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
InstanceCmd::Java {
|
||||
instance,
|
||||
home,
|
||||
auto,
|
||||
memory,
|
||||
args,
|
||||
} => {
|
||||
if instances::find(&instance)?.java.is_none() {
|
||||
return Err(format!(
|
||||
"{instance} is a Horizon instance: Horizon is not a Java program."
|
||||
));
|
||||
}
|
||||
changed(instances::edit(&instance, |m| {
|
||||
let java = m.java.get_or_insert_with(Default::default);
|
||||
if auto {
|
||||
java.home = None;
|
||||
}
|
||||
java.home = home.or(java.home.take());
|
||||
java.memory_mb = memory.or(java.memory_mb);
|
||||
if let Some(args) = args {
|
||||
java.jvm_args = args.trim().to_string();
|
||||
}
|
||||
})?)
|
||||
}
|
||||
InstanceCmd::Rename { instance, name } => {
|
||||
changed(instances::edit(&instance, |m| m.name = Some(name))?)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fn maker(root: &Path) -> impl Fn(&str) -> Result<Instance, String> + '_ {
|
||||
played: Duration::ZERO,
|
||||
last_played: None,
|
||||
launch: Default::default(),
|
||||
java: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,31 @@ pub struct Meta {
|
||||
/// What runs around the game: a pre-launch command, a wrapper, variables (`launch`).
|
||||
#[serde(default, skip_serializing_if = "Launch::is_empty")]
|
||||
pub launch: Launch,
|
||||
/// Set for a Minecraft: Java Edition instance: it plays the real game, not Horizon.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java: Option<JavaGame>,
|
||||
}
|
||||
|
||||
/// A Minecraft: Java Edition instance: the real game of one version, with the Java it runs on
|
||||
/// (`crate::java` picks one when `home` is none).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JavaGame {
|
||||
/// The Minecraft version, as Mojang names it (`1.21.4`).
|
||||
pub minecraft: String,
|
||||
/// A Java home to run it on instead of the automatic pick.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub home: Option<PathBuf>,
|
||||
/// The most memory the game may use, in MiB (`-Xmx`); none: [`DEFAULT_MEMORY_MB`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_mb: Option<u32>,
|
||||
/// Extra JVM arguments, split on whitespace.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub jvm_args: String,
|
||||
}
|
||||
|
||||
/// 4 GiB: enough for vanilla and most modpacks, as other launchers start with.
|
||||
pub const DEFAULT_MEMORY_MB: u32 = 4096;
|
||||
|
||||
fn is_zero(n: &u64) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
@@ -146,12 +169,28 @@ pub struct Instance {
|
||||
pub last_played: Option<SystemTime>,
|
||||
/// What runs around the game (`launch::wrap`).
|
||||
pub launch: Launch,
|
||||
/// The Minecraft: Java Edition it plays, for a Java instance.
|
||||
pub java: Option<JavaGame>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Ok when it plays Horizon; a Java instance's refusal, until the launcher starts the real
|
||||
/// game.
|
||||
fn horizon(&self) -> Result<(), String> {
|
||||
match &self.java {
|
||||
None => Ok(()),
|
||||
Some(java) => Err(format!(
|
||||
"{} plays Minecraft {} (Java Edition), which this launcher can't start yet: playing \
|
||||
Java instances comes in a coming update.",
|
||||
self.name, java.minecraft
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed version this instance plays: its pinned one, else `requested` (the
|
||||
/// command line's `--version`) or the launcher's (`backend::resolve_version`).
|
||||
pub fn resolve_version(&self, requested: Option<&str>) -> Result<String, String> {
|
||||
self.horizon()?;
|
||||
match (requested, &self.version) {
|
||||
(None, Some(pinned)) => backend::resolve_version(Some(pinned)).map_err(|_| {
|
||||
format!(
|
||||
@@ -220,6 +259,7 @@ fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
|
||||
.last_played
|
||||
.map(|s| UNIX_EPOCH + Duration::from_secs(s)),
|
||||
launch: meta.launch,
|
||||
java: meta.java,
|
||||
id,
|
||||
kind,
|
||||
work,
|
||||
@@ -291,6 +331,19 @@ pub fn create(name: &str, from: Option<&str>) -> Result<Instance, String> {
|
||||
&backend::data_dir(),
|
||||
name,
|
||||
from.as_ref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Minecraft: Java Edition instance named `name` playing `minecraft` (a version id
|
||||
/// from Mojang's list), with an empty game folder: the real game keeps its own options.
|
||||
pub fn create_java(name: &str, minecraft: &str) -> Result<Instance, String> {
|
||||
create_in(
|
||||
&backend::game_home(),
|
||||
&backend::data_dir(),
|
||||
name,
|
||||
None,
|
||||
Some(minecraft),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +352,7 @@ fn create_in(
|
||||
data: &Path,
|
||||
name: &str,
|
||||
from: Option<&Instance>,
|
||||
minecraft: Option<&str>,
|
||||
) -> Result<Instance, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -329,6 +383,7 @@ fn create_in(
|
||||
let dir = game_home.join(OWN).join(&slug);
|
||||
let made = match from {
|
||||
Some(from) => copy_home(&from.home, &dir),
|
||||
None if minecraft.is_some() => fs::create_dir_all(&dir),
|
||||
None => fs::create_dir_all(&dir).and_then(|()| {
|
||||
for f in PLAYER_FILES {
|
||||
let own = game_home.join(f);
|
||||
@@ -348,6 +403,12 @@ fn create_in(
|
||||
meta.name = (name != slug).then(|| name.to_string());
|
||||
meta.icon = from.and_then(|f| f.icon.clone());
|
||||
meta.group = from.and_then(|f| f.group.clone());
|
||||
meta.java = from.and_then(|f| f.java.clone()).or_else(|| {
|
||||
minecraft.map(|v| JavaGame {
|
||||
minecraft: v.to_string(),
|
||||
..JavaGame::default()
|
||||
})
|
||||
});
|
||||
store.save(data)?;
|
||||
find_in(&list_in(game_home, data), &slug)
|
||||
}
|
||||
@@ -459,6 +520,7 @@ pub fn play_time(played: Duration) -> String {
|
||||
/// player's answers that hold everywhere follow it in: data collection and No AI as the
|
||||
/// launcher has them, and options.txt and the offline name when it has none yet.
|
||||
pub fn apply(cmd: &mut Command, instance: &Instance) -> Result<(), String> {
|
||||
instance.horizon()?;
|
||||
match instance.kind {
|
||||
Kind::Default => return Ok(()),
|
||||
Kind::Own => {
|
||||
|
||||
@@ -112,8 +112,8 @@ fn existing_worlds_stay_where_they_are_and_every_folder_becomes_an_instance() {
|
||||
assert_eq!(all[2].home, home.join("ports/small-pack/home"));
|
||||
|
||||
// Everything the player can do with instances, then not one byte of theirs has moved.
|
||||
let empty = create_in(&home, &data, "Survival", None).unwrap();
|
||||
let copy = create_in(&home, &data, "My copy", Some(&all[0])).unwrap();
|
||||
let empty = create_in(&home, &data, "Survival", None, None).unwrap();
|
||||
let copy = create_in(&home, &data, "My copy", Some(&all[0]), None).unwrap();
|
||||
edit_in(&home, &data, DEFAULT, |m| m.name = Some("Main".into())).unwrap();
|
||||
edit_in(&home, &data, "Small Pack", |m| {
|
||||
m.icon = Some(Icon::Texture("diamond".into()));
|
||||
@@ -160,18 +160,18 @@ fn names_stay_unique_and_packs_are_not_copied() {
|
||||
let root = scratch("names");
|
||||
let (home, data) = (root.join("horizon"), root.join("launcher"));
|
||||
player_home(&home);
|
||||
create_in(&home, &data, "Survival", None).unwrap();
|
||||
assert!(create_in(&home, &data, "survival", None).is_err());
|
||||
assert!(create_in(&home, &data, " ", None).is_err());
|
||||
create_in(&home, &data, "Survival", None, None).unwrap();
|
||||
assert!(create_in(&home, &data, "survival", None, None).is_err());
|
||||
assert!(create_in(&home, &data, " ", None, None).is_err());
|
||||
// Another name with the same folder name gets the next free folder.
|
||||
let second = create_in(&home, &data, "Survival!", None).unwrap();
|
||||
let second = create_in(&home, &data, "Survival!", None, None).unwrap();
|
||||
assert_eq!(second.id, "survival-2");
|
||||
assert_eq!(second.name, "Survival!");
|
||||
assert!(edit_in(&home, &data, "survival-2", |m| m.name =
|
||||
Some("SURVIVAL".into()))
|
||||
.is_err());
|
||||
let port = find_in(&list_in(&home, &data), "small-pack").unwrap();
|
||||
assert!(create_in(&home, &data, "Pack copy", Some(&port)).is_err());
|
||||
assert!(create_in(&home, &data, "Pack copy", Some(&port), None).is_err());
|
||||
assert!(!home.join("instances/pack-copy").exists());
|
||||
// An empty group is no group.
|
||||
let grouped = edit_in(&home, &data, "Survival", |m| m.group = Some(" ".into())).unwrap();
|
||||
@@ -246,7 +246,7 @@ fn an_instance_keeps_its_own_version_until_it_follows_updates_again() {
|
||||
let root = scratch("version");
|
||||
let (home, data) = (root.join("horizon"), root.join("launcher"));
|
||||
player_home(&home);
|
||||
create_in(&home, &data, "Old world", None).unwrap();
|
||||
create_in(&home, &data, "Old world", None, None).unwrap();
|
||||
let pinned = edit_in(&home, &data, "Old world", |m| {
|
||||
m.version = Some("v0.1.0".into())
|
||||
})
|
||||
@@ -297,3 +297,40 @@ fn play_time_reads_as_minutes_then_hours() {
|
||||
assert_eq!(t(3 * 3600 + 5 * 60), "3 h 05 min");
|
||||
assert_eq!(t(140 * 3600 + 7 * 60), "140 h");
|
||||
}
|
||||
|
||||
/// A Java instance: an empty folder of its own (the real game keeps its own options), its
|
||||
/// version in instances.json, kept by a copy, and refused by Play until the launcher starts
|
||||
/// the real game; Horizon instances' entries stay as they were.
|
||||
#[test]
|
||||
fn a_java_instance_plays_one_minecraft_version_in_its_own_folder() {
|
||||
let root = scratch("java");
|
||||
let (home, data) = (root.join("horizon"), root.join("launcher"));
|
||||
player_home(&home);
|
||||
let java = create_in(&home, &data, "Vanilla 1.21.4", None, Some("1.21.4")).unwrap();
|
||||
assert_eq!(java.home, home.join(OWN).join("vanilla-1-21-4"));
|
||||
assert_eq!(fs::read_dir(&java.home).unwrap().count(), 0);
|
||||
assert_eq!(java.java.as_ref().unwrap().minecraft, "1.21.4");
|
||||
let copy = create_in(&home, &data, "Second", Some(&java), None).unwrap();
|
||||
assert_eq!(copy.java, java.java);
|
||||
let horizon = create_in(&home, &data, "Horizon two", None, None).unwrap();
|
||||
assert_eq!(horizon.java, None);
|
||||
let store = fs::read_to_string(data.join(FILE)).unwrap();
|
||||
assert_eq!(
|
||||
store.matches("\"minecraft\": \"1.21.4\"").count(),
|
||||
2,
|
||||
"{store}"
|
||||
);
|
||||
let mut cmd = Command::new("game");
|
||||
let refused = apply(&mut cmd, &java).unwrap_err();
|
||||
assert!(
|
||||
refused.contains("plays Minecraft 1.21.4 (Java Edition)"),
|
||||
"{refused}"
|
||||
);
|
||||
edit_in(&home, &data, &java.id, |m| {
|
||||
m.java.as_mut().unwrap().memory_mb = Some(6144)
|
||||
})
|
||||
.unwrap();
|
||||
let edited = find_in(&list_in(&home, &data), &java.id).unwrap();
|
||||
assert_eq!(edited.java.unwrap().memory_mb, Some(6144));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Java for the real Minecraft: Java Edition (Java instances): which runtime a version needs, the
|
||||
//! ones on this computer, and Mojang's own when none fits, as other launchers do.
|
||||
//!
|
||||
//! A version's JSON names what it runs on: `javaVersion.majorVersion` (21 for 1.21) and the
|
||||
//! `component` of Mojang's runtime built for it (`java-runtime-delta`). Versions older than that
|
||||
//! field ran Java 8 (`jre-legacy`). The game wants that major exactly: older versions and their
|
||||
//! loaders often fail on a newer Java, so a newer one is never picked by itself.
|
||||
//!
|
||||
//! Runtimes are found without starting them: each Java home has a `release` file whose
|
||||
//! `JAVA_VERSION` gives its version. Mojang's are kept in `minecraft/runtime/` in the launcher's
|
||||
//! data folder (`mojang::java_runtime` lays them out), the folder preparing uses too.
|
||||
|
||||
use crate::{backend, mojang, sources};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What a version runs on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Need {
|
||||
pub major: u32,
|
||||
/// Mojang's runtime for it, e.g. `java-runtime-delta`.
|
||||
pub component: String,
|
||||
}
|
||||
|
||||
impl Need {
|
||||
/// From a version JSON; Java 8 for versions from before `javaVersion`.
|
||||
pub fn of(version: &serde_json::Value) -> Need {
|
||||
let java = &version["javaVersion"];
|
||||
match (java["majorVersion"].as_u64(), java["component"].as_str()) {
|
||||
(Some(major), Some(component)) => Need {
|
||||
major: major as u32,
|
||||
component: component.to_string(),
|
||||
},
|
||||
_ => Need {
|
||||
major: 8,
|
||||
component: "jre-legacy".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mojang's runtime components by the major they carry, newest component of each.
|
||||
const COMPONENTS: [(u32, &str); 5] = [
|
||||
(8, "jre-legacy"),
|
||||
(16, "java-runtime-alpha"),
|
||||
(17, "java-runtime-gamma"),
|
||||
(21, "java-runtime-delta"),
|
||||
(25, "java-runtime-epsilon"),
|
||||
];
|
||||
|
||||
impl Need {
|
||||
/// A major (`21`) or a component name (`java-runtime-delta`), as typed.
|
||||
pub fn named(name: &str) -> Result<Need, String> {
|
||||
let name = name.trim();
|
||||
let by_major = name.parse::<u32>().ok().and_then(|major| {
|
||||
COMPONENTS
|
||||
.iter()
|
||||
.find(|(m, _)| *m == major)
|
||||
.map(|(m, c)| (*m, *c))
|
||||
});
|
||||
let by_name = COMPONENTS.iter().find(|(_, c)| *c == name).copied();
|
||||
let (major, component) = by_major.or(by_name).ok_or_else(|| {
|
||||
let known: Vec<String> = COMPONENTS
|
||||
.iter()
|
||||
.map(|(m, c)| format!("{m} ({c})"))
|
||||
.collect();
|
||||
format!("Mojang has no Java {name:?}. Known: {}.", known.join(", "))
|
||||
})?;
|
||||
Ok(Need {
|
||||
major,
|
||||
component: component.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a runtime comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
/// Mojang's, downloaded by Horizon.
|
||||
Mojang,
|
||||
/// Installed on this computer (JAVA_HOME, the PATH, the usual folders).
|
||||
Installed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Runtime {
|
||||
pub home: PathBuf,
|
||||
/// Its full version, e.g. `21.0.5`.
|
||||
pub version: String,
|
||||
pub major: u32,
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// The `java` program to start the game with.
|
||||
pub fn java(&self) -> PathBuf {
|
||||
self.home
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) { "javaw.exe" } else { "java" })
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a Java home's `release` file names (`JAVA_VERSION="21.0.5"`), with its major.
|
||||
pub fn release_version(home: &Path) -> Option<(String, u32)> {
|
||||
let text = fs::read_to_string(home.join("release")).ok()?;
|
||||
let version = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("JAVA_VERSION="))?
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
let mut parts = version.split(['.', '_', '-', '+']);
|
||||
let first: u32 = parts.next()?.parse().ok()?;
|
||||
let major = if first == 1 {
|
||||
parts.next()?.parse().ok()?
|
||||
} else {
|
||||
first
|
||||
};
|
||||
Some((version, major))
|
||||
}
|
||||
|
||||
fn runtime(home: PathBuf, source: Source) -> Option<Runtime> {
|
||||
let (version, major) = release_version(&home)?;
|
||||
let found = Runtime {
|
||||
home,
|
||||
version,
|
||||
major,
|
||||
source,
|
||||
};
|
||||
found.java().is_file().then_some(found)
|
||||
}
|
||||
|
||||
/// Mojang's runtimes Horizon downloaded.
|
||||
pub fn runtimes_dir() -> PathBuf {
|
||||
backend::data_dir().join("minecraft").join("runtime")
|
||||
}
|
||||
|
||||
/// The runtimes in a Mojang `runtime` folder.
|
||||
pub fn mojang_runtimes(dir: &Path) -> Vec<Runtime> {
|
||||
horizon_prepare::java::minecraft_runtimes(dir)
|
||||
.into_iter()
|
||||
.filter_map(|home| runtime(home, Source::Mojang))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Java homes installed on this computer: JAVA_HOME, the PATH's, then the folders installers use,
|
||||
/// each once.
|
||||
pub fn installed() -> Vec<Runtime> {
|
||||
let var = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty());
|
||||
let mut homes: Vec<PathBuf> = var("JAVA_HOME").map(PathBuf::from).into_iter().collect();
|
||||
if let Some(path) = var("PATH") {
|
||||
homes.extend(
|
||||
std::env::split_paths(&path)
|
||||
.filter(|dir| dir.file_name().is_some_and(|n| n == "bin"))
|
||||
.filter_map(|dir| fs::canonicalize(dir.join("java")).ok())
|
||||
.filter_map(|java| Some(java.parent()?.parent()?.to_path_buf())),
|
||||
);
|
||||
}
|
||||
for parent in parents() {
|
||||
for entry in fs::read_dir(parent).into_iter().flatten().flatten() {
|
||||
let bundle = entry.path().join("Contents/Home");
|
||||
homes.push(if bundle.is_dir() {
|
||||
bundle
|
||||
} else {
|
||||
entry.path()
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut found: Vec<Runtime> = Vec::new();
|
||||
for home in homes {
|
||||
let home = fs::canonicalize(&home).unwrap_or(home);
|
||||
if !found.iter().any(|r| r.home == home) {
|
||||
found.extend(runtime(home, Source::Installed));
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The folders Java installers put homes in, one home per child.
|
||||
fn parents() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir();
|
||||
let mut parents: Vec<PathBuf> = match sources::Os::current() {
|
||||
sources::Os::MacOs => {
|
||||
let mut p = vec![PathBuf::from("/Library/Java/JavaVirtualMachines")];
|
||||
p.extend(
|
||||
home.iter()
|
||||
.map(|h| h.join("Library/Java/JavaVirtualMachines")),
|
||||
);
|
||||
p
|
||||
}
|
||||
sources::Os::Linux => ["/usr/lib/jvm", "/usr/java", "/usr/local/lib/jvm"]
|
||||
.map(PathBuf::from)
|
||||
.into(),
|
||||
sources::Os::Windows => ["ProgramFiles", "ProgramW6432"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.flat_map(|base| {
|
||||
let base = PathBuf::from(base);
|
||||
["Eclipse Adoptium", "Java", "Microsoft", "Zulu", "BellSoft"].map(|v| base.join(v))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
parents.extend(
|
||||
home.iter()
|
||||
.flat_map(|h| [h.join(".jdks"), h.join(".sdkman/candidates/java")]),
|
||||
);
|
||||
parents
|
||||
}
|
||||
|
||||
/// The runtime for `need`: the Java home an instance names when it names one, else Mojang's
|
||||
/// runtime of that major, else an installed one of that major. None: Mojang's is to be downloaded.
|
||||
pub fn pick(
|
||||
need: &Need,
|
||||
chosen: Option<&Path>,
|
||||
mojang: &[Runtime],
|
||||
installed: &[Runtime],
|
||||
) -> Result<Option<Runtime>, String> {
|
||||
if let Some(home) = chosen {
|
||||
return runtime(home.to_path_buf(), Source::Installed)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"{} is not a Java home (no release file or java)",
|
||||
home.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
Ok(mojang
|
||||
.iter()
|
||||
.chain(installed)
|
||||
.find(|r| r.major == need.major)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
/// Downloads Mojang's runtime for `need` into [`runtimes_dir`] (what is already there and whole
|
||||
/// is kept).
|
||||
pub fn install(need: &Need, log: &dyn Fn(String)) -> Result<Runtime, String> {
|
||||
// Mojang builds its Linux Java for glibc: it cannot start without glibc's loader.
|
||||
if cfg!(all(target_os = "linux", target_arch = "x86_64"))
|
||||
&& !Path::new(backend::GLIBC_LOADER).exists()
|
||||
{
|
||||
return Err(format!(
|
||||
"Mojang's Java needs glibc ({} is missing; on NixOS, enable programs.nix-ld). Install Java {} and pick it instead.",
|
||||
backend::GLIBC_LOADER,
|
||||
need.major
|
||||
));
|
||||
}
|
||||
let fetch = |url: &str| -> Result<Vec<u8>, String> {
|
||||
let mut body = Vec::new();
|
||||
std::io::Read::read_to_end(&mut backend::get(url)?.into_reader(), &mut body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(body)
|
||||
};
|
||||
let home = mojang::java_runtime(
|
||||
&need.component,
|
||||
&runtimes_dir(),
|
||||
8,
|
||||
&fetch,
|
||||
&mojang::CANCEL,
|
||||
log,
|
||||
)?;
|
||||
let home = ["jre.bundle/Contents/Home", ""]
|
||||
.iter()
|
||||
.map(|sub| home.join(sub))
|
||||
.find(|h| h.join("release").is_file())
|
||||
.unwrap_or(home);
|
||||
runtime(home, Source::Mojang)
|
||||
.ok_or_else(|| format!("Mojang's {} has no usable java", need.component))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "java/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Fake Java homes in a scratch folder: a `release` file and an empty `bin/java`.
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-java-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn fake_home(home: &Path, version: &str) -> PathBuf {
|
||||
// A Windows home has both: the runtime search looks for java.exe, the game starts javaw.exe.
|
||||
let programs: &[&str] = if cfg!(windows) {
|
||||
&["java.exe", "javaw.exe"]
|
||||
} else {
|
||||
&["java"]
|
||||
};
|
||||
fs::create_dir_all(home.join("bin")).unwrap();
|
||||
for program in programs {
|
||||
fs::write(home.join("bin").join(program), b"").unwrap();
|
||||
}
|
||||
fs::write(
|
||||
home.join("release"),
|
||||
format!("IMPLEMENTOR=\"Fake\"\nJAVA_VERSION=\"{version}\"\nOS_NAME=\"Darwin\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
home.to_path_buf()
|
||||
}
|
||||
|
||||
fn need(major: u32) -> Need {
|
||||
Need {
|
||||
major,
|
||||
component: format!("c{major}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_names_its_java_and_old_ones_ran_java_8() {
|
||||
let new =
|
||||
serde_json::json!({"javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}});
|
||||
assert_eq!(
|
||||
Need::of(&new),
|
||||
Need {
|
||||
major: 21,
|
||||
component: "java-runtime-delta".into()
|
||||
}
|
||||
);
|
||||
let old = serde_json::json!({"id": "1.12.2"});
|
||||
assert_eq!(
|
||||
Need::of(&old),
|
||||
Need {
|
||||
major: 8,
|
||||
component: "jre-legacy".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_release_file_gives_the_version_java_8_included() {
|
||||
let dir = scratch("release");
|
||||
for (version, major) in [
|
||||
("21.0.5", 21),
|
||||
("1.8.0_432", 8),
|
||||
("25", 25),
|
||||
("17.0.12+7", 17),
|
||||
] {
|
||||
let home = fake_home(&dir.join(version), version);
|
||||
assert_eq!(release_version(&home), Some((version.to_string(), major)));
|
||||
}
|
||||
assert_eq!(release_version(&dir.join("none")), None);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mojangs_runtimes_are_found_in_their_layout() {
|
||||
let dir = scratch("mojang");
|
||||
let platform = mojang::java_platform().unwrap_or("linux");
|
||||
let delta = fake_home(
|
||||
&dir.join(format!("java-runtime-delta/{platform}/java-runtime-delta")),
|
||||
"21.0.5",
|
||||
);
|
||||
// macOS runtimes keep their home inside a bundle.
|
||||
let legacy = fake_home(
|
||||
&dir.join(format!(
|
||||
"jre-legacy/{platform}/jre-legacy/jre.bundle/Contents/Home"
|
||||
)),
|
||||
"1.8.0_432",
|
||||
);
|
||||
let found = mojang_runtimes(&dir);
|
||||
let homes: Vec<_> = found.iter().map(|r| (r.home.clone(), r.major)).collect();
|
||||
assert_eq!(homes, [(delta, 21), (legacy, 8)]);
|
||||
assert!(found.iter().all(|r| r.source == Source::Mojang));
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_is_the_exact_major_mojangs_first_never_a_newer_one() {
|
||||
let dir = scratch("pick");
|
||||
let rt = |name: &str, version: &str, source| {
|
||||
runtime(fake_home(&dir.join(name), version), source).unwrap()
|
||||
};
|
||||
let mojang = [rt("m21", "21.0.5", Source::Mojang)];
|
||||
let installed = [
|
||||
rt("i25", "25.0.1", Source::Installed),
|
||||
rt("i21", "21.0.3", Source::Installed),
|
||||
rt("i17", "17.0.12", Source::Installed),
|
||||
];
|
||||
let picked = |n| pick(&need(n), None, &mojang, &installed).unwrap();
|
||||
assert_eq!(picked(21).unwrap().home, dir.join("m21"));
|
||||
assert_eq!(picked(17).unwrap().home, dir.join("i17"));
|
||||
// Java 8 is needed and only newer ones are here: Mojang's is to be downloaded.
|
||||
assert_eq!(picked(8), None);
|
||||
assert_eq!(pick(&need(8), None, &[], &[]).unwrap(), None);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instances_own_java_wins_and_a_wrong_folder_says_so() {
|
||||
let dir = scratch("own");
|
||||
let own = fake_home(&dir.join("own"), "17.0.12");
|
||||
let picked = pick(&need(21), Some(&own), &[], &[]).unwrap().unwrap();
|
||||
assert_eq!((picked.home, picked.major), (own, 17));
|
||||
assert!(pick(&need(21), Some(&dir.join("nothing")), &[], &[])
|
||||
.unwrap_err()
|
||||
.contains("is not a Java home"));
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_java_is_a_major_or_a_component() {
|
||||
assert_eq!(Need::named("21").unwrap().component, "java-runtime-delta");
|
||||
assert_eq!(Need::named(" jre-legacy ").unwrap().major, 8);
|
||||
assert!(Need::named("11")
|
||||
.unwrap_err()
|
||||
.contains("Known: 8 (jre-legacy)"));
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
|
||||
//! official launcher reads it: the main class, the classpath of this system's libraries and the
|
||||
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
|
||||
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
|
||||
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
|
||||
//!
|
||||
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
|
||||
//! one game folder. Nothing here reaches the network.
|
||||
|
||||
use crate::mojang;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub uuid: String,
|
||||
pub token: String,
|
||||
pub xuid: String,
|
||||
}
|
||||
|
||||
/// Everything the command needs besides the version JSON.
|
||||
pub struct Launch<'a> {
|
||||
/// Holds `versions/`, `libraries/`, `assets/`.
|
||||
pub minecraft: &'a Path,
|
||||
/// The instance's game folder (saves, options.txt, mods).
|
||||
pub game_dir: &'a Path,
|
||||
/// Where an old version's natives are unpacked.
|
||||
pub natives: &'a Path,
|
||||
/// The `java` program.
|
||||
pub java: &'a Path,
|
||||
pub memory_mb: u32,
|
||||
/// The player's own JVM arguments.
|
||||
pub jvm_args: &'a [String],
|
||||
pub player: &'a Player,
|
||||
/// `osx`, `linux` or `windows`, as version files name systems.
|
||||
pub os: &'a str,
|
||||
}
|
||||
|
||||
/// This system's name in version files.
|
||||
pub fn os_name() -> &'static str {
|
||||
match crate::sources::Os::current() {
|
||||
crate::sources::Os::Linux => "linux",
|
||||
crate::sources::Os::MacOs => "osx",
|
||||
crate::sources::Os::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
|
||||
/// Every optional feature (demo, custom resolution, quick play) is off.
|
||||
fn allowed(rules: &Value, os: &str) -> bool {
|
||||
let Some(rules) = rules.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let arch_matches = |arch: &str| match arch {
|
||||
"x86" => cfg!(target_arch = "x86"),
|
||||
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
|
||||
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
|
||||
_ => false,
|
||||
};
|
||||
let mut allow = false;
|
||||
for rule in rules {
|
||||
let system = &rule["os"];
|
||||
let matches = system["name"].as_str().is_none_or(|n| n == os)
|
||||
&& system["arch"].as_str().is_none_or(arch_matches)
|
||||
&& rule["features"]
|
||||
.as_object()
|
||||
.is_none_or(|f| f.values().all(|v| v == false));
|
||||
if matches {
|
||||
allow = rule["action"] == "allow";
|
||||
}
|
||||
}
|
||||
allow
|
||||
}
|
||||
|
||||
/// The libraries this system uses, then the client jar.
|
||||
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let id = version["id"].as_str().ok_or("The version file has no id")?;
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
|
||||
let path = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.push(minecraft.join(format!("versions/{id}/{id}.jar")));
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
|
||||
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
|
||||
pub fn unpack_natives(
|
||||
version: &Value,
|
||||
minecraft: &Path,
|
||||
os: &str,
|
||||
to: &Path,
|
||||
) -> Result<(), String> {
|
||||
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = mojang::native_classifier(library, os) else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
let exclude: Vec<&str> = library["extract"]["exclude"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(name) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let text = name.to_string_lossy().replace('\\', "/");
|
||||
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
|
||||
continue;
|
||||
}
|
||||
let out = to.join(&name);
|
||||
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
|
||||
/// the objects copied there once; the asset store itself for every other version.
|
||||
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
|
||||
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
|
||||
let assets = minecraft.join("assets");
|
||||
let index = assets
|
||||
.join("indexes")
|
||||
.join(mojang::relative(&format!("{id}.json"))?);
|
||||
let Ok(text) = fs::read_to_string(&index) else {
|
||||
return Ok(assets);
|
||||
};
|
||||
let index: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
|
||||
let to = if index["map_to_resources"] == true {
|
||||
game_dir.join("resources")
|
||||
} else if index["virtual"] == true {
|
||||
assets.join("virtual").join(mojang::relative(id)?)
|
||||
} else {
|
||||
return Ok(assets);
|
||||
};
|
||||
for (name, object) in index["objects"].as_object().into_iter().flatten() {
|
||||
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
|
||||
continue;
|
||||
};
|
||||
let out = to.join(mojang::relative(name)?);
|
||||
if !out.is_file() {
|
||||
let from = assets.join("objects").join(&hash[..2]).join(hash);
|
||||
fs::create_dir_all(out.parent().unwrap())
|
||||
.and_then(|_| fs::copy(&from, &out))
|
||||
.map_err(|e| format!("{}: {e}", from.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
|
||||
fn list(args: &Value, os: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for arg in args.as_array().into_iter().flatten() {
|
||||
match arg {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Array(values) => {
|
||||
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `${name}` replaced by its value; an unknown one stays as written.
|
||||
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = arg;
|
||||
while let Some(start) = rest.find("${") {
|
||||
let Some(len) = rest[start..].find('}') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..start]);
|
||||
let key = &rest[start + 2..start + len];
|
||||
match vars.get(key) {
|
||||
Some(value) => out.push_str(value),
|
||||
None => out.push_str(&rest[start..start + len + 1]),
|
||||
}
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
|
||||
/// Unpacks natives and lays out old assets first.
|
||||
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
|
||||
let id = version["id"].as_str().ok_or("The version file has no id")?;
|
||||
let main = version["mainClass"]
|
||||
.as_str()
|
||||
.ok_or("The version file has no mainClass")?;
|
||||
let separator = if cfg!(windows) { ";" } else { ":" };
|
||||
let classpath = classpath(version, launch.minecraft, launch.os)?;
|
||||
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
|
||||
let assets = launch.minecraft.join("assets");
|
||||
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
|
||||
let path = |p: &Path| p.to_string_lossy().into_owned();
|
||||
let p = launch.player;
|
||||
let vars: HashMap<&str, String> = HashMap::from([
|
||||
("auth_player_name", p.name.clone()),
|
||||
("auth_uuid", p.uuid.clone()),
|
||||
("auth_access_token", p.token.clone()),
|
||||
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
|
||||
("auth_xuid", p.xuid.clone()),
|
||||
("clientid", String::new()),
|
||||
("user_type", "msa".into()),
|
||||
("user_properties", "{}".into()),
|
||||
("version_name", id.to_string()),
|
||||
(
|
||||
"version_type",
|
||||
version["type"].as_str().unwrap_or("release").into(),
|
||||
),
|
||||
("game_directory", path(launch.game_dir)),
|
||||
("assets_root", path(&assets)),
|
||||
("game_assets", path(&game_assets)),
|
||||
(
|
||||
"assets_index_name",
|
||||
version["assetIndex"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or("legacy")
|
||||
.into(),
|
||||
),
|
||||
("natives_directory", path(launch.natives)),
|
||||
(
|
||||
"library_directory",
|
||||
path(&launch.minecraft.join("libraries")),
|
||||
),
|
||||
("classpath_separator", separator.into()),
|
||||
(
|
||||
"classpath",
|
||||
classpath
|
||||
.iter()
|
||||
.map(|c| path(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator),
|
||||
),
|
||||
("launcher_name", "horizon-launcher".into()),
|
||||
("launcher_version", env!("CARGO_PKG_VERSION").into()),
|
||||
]);
|
||||
let mut jvm = vec![
|
||||
format!("-Xmx{}M", launch.memory_mb),
|
||||
// Whatever the logging setup, never look names up in log messages.
|
||||
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
|
||||
];
|
||||
let logging = &version["logging"]["client"];
|
||||
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
|
||||
{
|
||||
let config = launch
|
||||
.minecraft
|
||||
.join("assets/log_configs")
|
||||
.join(mojang::relative(file)?);
|
||||
jvm.push(arg.replace("${path}", &path(&config)));
|
||||
}
|
||||
let (jvm_list, game) = match version["arguments"].as_object() {
|
||||
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
|
||||
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
|
||||
None => (
|
||||
vec![
|
||||
"-Djava.library.path=${natives_directory}".into(),
|
||||
"-cp".into(),
|
||||
"${classpath}".into(),
|
||||
],
|
||||
version["minecraftArguments"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
|
||||
jvm.extend(launch.jvm_args.iter().cloned());
|
||||
let mut cmd = Command::new(launch.java);
|
||||
cmd.current_dir(launch.game_dir)
|
||||
.args(&jvm)
|
||||
.arg(main)
|
||||
.args(game.iter().map(|a| fill(a, &vars)));
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "java_launch/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
|
||||
//! a scratch folder.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn player() -> Player {
|
||||
Player {
|
||||
name: "Notch".into(),
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
|
||||
token: "mc-token".into(),
|
||||
xuid: "2535400000000000".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn args(cmd: &Command) -> Vec<String> {
|
||||
cmd.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn modern() -> Value {
|
||||
json!({
|
||||
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "19"},
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
|
||||
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
|
||||
"--userType", "${user_type}",
|
||||
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
|
||||
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
|
||||
"value": ["--width", "${resolution_width}"]}],
|
||||
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
|
||||
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
|
||||
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
|
||||
"-cp", "${classpath}"]
|
||||
},
|
||||
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
|
||||
"file": {"id": "client-1.21.2.xml"}}},
|
||||
"libraries": [
|
||||
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
|
||||
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
|
||||
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
|
||||
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
|
||||
let dir = scratch("modern");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let java = dir.join("java");
|
||||
let p = player();
|
||||
let extra = vec!["-XX:+UseZGC".to_string()];
|
||||
let launch = |os| Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 6144,
|
||||
jvm_args: &extra,
|
||||
player: &p,
|
||||
os,
|
||||
};
|
||||
let cmd = command(&modern(), &launch("osx")).unwrap();
|
||||
let a = args(&cmd);
|
||||
let at = |s: &str| {
|
||||
a.iter()
|
||||
.position(|x| x == s)
|
||||
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
|
||||
};
|
||||
assert_eq!(a[0], "-Xmx6144M");
|
||||
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
|
||||
let config = mc.join("assets/log_configs/client-1.21.2.xml");
|
||||
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
|
||||
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
|
||||
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
|
||||
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
|
||||
assert_eq!(a[at("--username") + 1], "Notch");
|
||||
assert_eq!(a[at("--accessToken") + 1], "mc-token");
|
||||
assert_eq!(a[at("--assetIndex") + 1], "19");
|
||||
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
|
||||
assert_eq!(a[at("--userType") + 1], "msa");
|
||||
// Features are off: no demo, no custom size.
|
||||
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
|
||||
let lib = |p: &str| mc.join("libraries").join(p).to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
cp,
|
||||
[
|
||||
lib("a/common/1/common-1.jar"),
|
||||
lib("a/mac/1/mac-1.jar"),
|
||||
mc.join("versions/1.21.4/1.21.4.jar")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
|
||||
|
||||
let linux = args(&command(&modern(), &launch("linux")).unwrap());
|
||||
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
|
||||
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
|
||||
// The x86-only rule holds on 32-bit Windows alone.
|
||||
let windows = args(&command(&modern(), &launch("windows")).unwrap());
|
||||
assert_eq!(
|
||||
windows.contains(&"-Xss1M".into()),
|
||||
cfg!(target_arch = "x86")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
|
||||
/// virtual asset index laid out by name.
|
||||
#[test]
|
||||
fn an_old_version_gets_its_natives_and_assets_by_name() {
|
||||
let dir = scratch("old");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
|
||||
fs::create_dir_all(jar.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for (name, body) in [
|
||||
("liblwjgl.dylib", b"lib".as_slice()),
|
||||
("META-INF/MANIFEST.MF", b"m"),
|
||||
] {
|
||||
zip.start_file(name, opts).unwrap();
|
||||
zip.write_all(body).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let hash = "0123456789abcdef0123456789abcdef01234567";
|
||||
let object = mc.join("assets/objects/01").join(hash);
|
||||
fs::create_dir_all(object.parent().unwrap()).unwrap();
|
||||
fs::write(&object, b"ogg").unwrap();
|
||||
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
|
||||
fs::write(
|
||||
mc.join("assets/indexes/1.8.json"),
|
||||
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let version = json!({
|
||||
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "1.8"},
|
||||
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
|
||||
"libraries": [{
|
||||
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
|
||||
"natives": {"osx": "natives-osx"},
|
||||
"extract": {"exclude": ["META-INF/"]},
|
||||
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
|
||||
}]
|
||||
});
|
||||
let (java, p) = (dir.join("java"), player());
|
||||
let launch = Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 2048,
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "osx",
|
||||
};
|
||||
let a = args(&command(&version, &launch).unwrap());
|
||||
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
|
||||
assert!(!natives.join("META-INF").exists());
|
||||
let virtual_dir = mc.join("assets/virtual/1.8");
|
||||
assert_eq!(
|
||||
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
|
||||
b"ogg"
|
||||
);
|
||||
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
|
||||
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
a[at("--session") + 1],
|
||||
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
|
||||
);
|
||||
assert_eq!(a[at("--userProperties") + 1], "{}");
|
||||
assert_eq!(a[at("--unknown") + 1], "${nothing}");
|
||||
assert_eq!(a[0], "-Xmx2048M");
|
||||
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
|
||||
// Natives are not on the classpath; the client jar is.
|
||||
let cp = &a[at("-cp") + 1];
|
||||
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_fill_in_place() {
|
||||
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
|
||||
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
|
||||
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
|
||||
}
|
||||
@@ -14,7 +14,10 @@ pub mod heads;
|
||||
pub mod i18n;
|
||||
pub mod import;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
pub mod launch;
|
||||
pub mod minecraft_versions;
|
||||
pub mod modpacks;
|
||||
pub mod mojang;
|
||||
pub mod native_mods;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! The versions of Minecraft: Java Edition a Java instance can play: Mojang's public version list
|
||||
//! (`version_manifest_v2.json`, the one the official launcher reads), kept in the launcher's data
|
||||
//! folder so the list still shows offline. It names each version and where its JSON is; the game's
|
||||
//! files themselves are downloaded only for an account that owns the game (`mojang.rs`).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
|
||||
/// What kind of version it is, as Mojang lists it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
Release,
|
||||
Snapshot,
|
||||
OldBeta,
|
||||
OldAlpha,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Version {
|
||||
pub id: String,
|
||||
pub kind: Kind,
|
||||
/// Its version JSON, and that file's sha1.
|
||||
pub url: String,
|
||||
pub sha1: String,
|
||||
/// When it came out (ISO 8601).
|
||||
pub released: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Versions {
|
||||
pub latest_release: String,
|
||||
pub latest_snapshot: String,
|
||||
/// The newest first, as Mojang lists them.
|
||||
pub all: Vec<Version>,
|
||||
}
|
||||
|
||||
impl Versions {
|
||||
pub fn parse(text: &str) -> Result<Versions, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("Mojang's version list: {e}"))?;
|
||||
let all = json["versions"]
|
||||
.as_array()
|
||||
.ok_or("Mojang's version list has no versions")?
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let kind = match v["type"].as_str()? {
|
||||
"release" => Kind::Release,
|
||||
"snapshot" => Kind::Snapshot,
|
||||
"old_beta" => Kind::OldBeta,
|
||||
"old_alpha" => Kind::OldAlpha,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Version {
|
||||
id: v["id"].as_str()?.to_string(),
|
||||
kind,
|
||||
url: v["url"].as_str()?.to_string(),
|
||||
sha1: v["sha1"].as_str()?.to_string(),
|
||||
released: v["releaseTime"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let latest = |key: &str| json["latest"][key].as_str().unwrap_or("").to_string();
|
||||
Ok(Versions {
|
||||
latest_release: latest("release"),
|
||||
latest_snapshot: latest("snapshot"),
|
||||
all,
|
||||
})
|
||||
}
|
||||
|
||||
/// The version `name` names: an id (`1.21.4`), `latest` (the newest release) or
|
||||
/// `latest-snapshot`.
|
||||
pub fn find(&self, name: &str) -> Result<&Version, String> {
|
||||
let id = match name.trim() {
|
||||
"latest" | "release" => self.latest_release.as_str(),
|
||||
"latest-snapshot" | "snapshot" => self.latest_snapshot.as_str(),
|
||||
id => id,
|
||||
};
|
||||
self.all.iter().find(|v| v.id == id).ok_or_else(|| {
|
||||
format!("Minecraft has no version {name:?}. `horizon-launcher instance versions` lists them.")
|
||||
})
|
||||
}
|
||||
|
||||
/// The versions of the kinds asked for, the newest first.
|
||||
pub fn of(&self, snapshots: bool, old: bool) -> impl Iterator<Item = &Version> {
|
||||
self.all.iter().filter(move |v| match v.kind {
|
||||
Kind::Release => true,
|
||||
Kind::Snapshot => snapshots,
|
||||
Kind::OldBeta | Kind::OldAlpha => old,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn cache() -> PathBuf {
|
||||
crate::backend::data_dir()
|
||||
.join("minecraft")
|
||||
.join("version_manifest_v2.json")
|
||||
}
|
||||
|
||||
/// Mojang's list, fetched now and kept; the kept one when Mojang can't be reached.
|
||||
pub fn load() -> Result<Versions, String> {
|
||||
load_with(&cache(), &|url| {
|
||||
let mut body = String::new();
|
||||
std::io::Read::read_to_string(&mut crate::backend::get(url)?.into_reader(), &mut body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(body)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_with(
|
||||
cache: &Path,
|
||||
fetch: &dyn Fn(&str) -> Result<String, String>,
|
||||
) -> Result<Versions, String> {
|
||||
match fetch(MANIFEST).and_then(|text| Versions::parse(&text).map(|v| (v, text))) {
|
||||
Ok((versions, text)) => {
|
||||
let _ =
|
||||
fs::create_dir_all(cache.parent().unwrap()).and_then(|_| fs::write(cache, text));
|
||||
Ok(versions)
|
||||
}
|
||||
Err(e) => fs::read_to_string(cache)
|
||||
.map_err(|_| format!("Mojang's version list could not be fetched: {e}"))
|
||||
.and_then(|text| Versions::parse(&text)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LIST: &str = r#"{
|
||||
"latest": {"release": "1.21.4", "snapshot": "25w02a"},
|
||||
"versions": [
|
||||
{"id": "25w02a", "type": "snapshot", "url": "https://piston-meta.mojang.com/v1/packages/a/25w02a.json", "sha1": "a", "releaseTime": "2025-01-08T12:00:00+00:00"},
|
||||
{"id": "1.21.4", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/b/1.21.4.json", "sha1": "b", "releaseTime": "2024-12-03T10:12:57+00:00"},
|
||||
{"id": "b1.7.3", "type": "old_beta", "url": "https://piston-meta.mojang.com/v1/packages/c/b1.7.3.json", "sha1": "c", "releaseTime": "2011-07-07T22:00:00+00:00"},
|
||||
{"id": "x", "type": "experiment", "url": "u", "sha1": "d"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn the_list_names_each_version_and_the_latest_ones() {
|
||||
let v = Versions::parse(LIST).unwrap();
|
||||
assert_eq!(v.all.len(), 3, "unknown kinds are left out");
|
||||
assert_eq!(v.find("latest").unwrap().id, "1.21.4");
|
||||
assert_eq!(v.find("latest-snapshot").unwrap().kind, Kind::Snapshot);
|
||||
assert_eq!(v.find("b1.7.3").unwrap().sha1, "c");
|
||||
assert!(v.find("1.99").unwrap_err().contains("no version \"1.99\""));
|
||||
let ids = |s, o| v.of(s, o).map(|v| v.id.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(ids(false, false), ["1.21.4"]);
|
||||
assert_eq!(ids(true, true), ["25w02a", "1.21.4", "b1.7.3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kept_list_shows_offline() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mc-versions-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let cache = dir.join("minecraft/version_manifest_v2.json");
|
||||
let offline = |_: &str| -> Result<String, String> { Err("no network".into()) };
|
||||
assert!(load_with(&cache, &offline)
|
||||
.unwrap_err()
|
||||
.contains("could not be fetched: no network"));
|
||||
let online = |url: &str| -> Result<String, String> {
|
||||
assert_eq!(url, MANIFEST);
|
||||
Ok(LIST.into())
|
||||
};
|
||||
assert_eq!(load_with(&cache, &online).unwrap().latest_release, "1.21.4");
|
||||
assert_eq!(load_with(&cache, &offline).unwrap().all.len(), 3);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
+50
-13
@@ -60,9 +60,15 @@ pub fn download(
|
||||
fetch_manifest(pins, &manifest, fetch, log)?;
|
||||
let manifest = read_json(&manifest)?;
|
||||
let client = &manifest["downloads"]["client"];
|
||||
// A version Horizon doesn't pin takes its client's sha1 from its version file, itself
|
||||
// checked against the sha1 Mojang's version list gives (`Pins::for_version`).
|
||||
let client_sha1 = match pins.client_sha1.as_str() {
|
||||
"" => text(client, "sha1")?,
|
||||
pinned => pinned.to_string(),
|
||||
};
|
||||
let mut game = vec![File {
|
||||
url: text(client, "url")?,
|
||||
sha1: pins.client_sha1.clone(),
|
||||
sha1: client_sha1,
|
||||
size: client["size"].as_u64(),
|
||||
path: versions.join(format!("{v}.jar")),
|
||||
}];
|
||||
@@ -70,22 +76,40 @@ pub fn download(
|
||||
.as_array()
|
||||
.ok_or("The version file has no libraries")?;
|
||||
for library in libraries.iter().filter(|l| allowed(l, os)) {
|
||||
let artifact = &library["downloads"]["artifact"];
|
||||
if artifact.is_null() {
|
||||
continue;
|
||||
// Before 1.19 a library's natives for this system are a classifier of its own.
|
||||
let native = native_classifier(library, os)
|
||||
.map(|key| &library["downloads"]["classifiers"][key.as_str()]);
|
||||
for artifact in [Some(&library["downloads"]["artifact"]), native]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_null())
|
||||
{
|
||||
game.push(File {
|
||||
url: text(artifact, "url")?,
|
||||
sha1: text(artifact, "sha1")?,
|
||||
size: artifact["size"].as_u64(),
|
||||
path: dir
|
||||
.join("libraries")
|
||||
.join(relative(&text(artifact, "path")?)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
let libraries = game.len() - 1;
|
||||
// The logging setup the game is started with (`logging.client.argument`): for 1.7 to 1.18,
|
||||
// Mojang's fixed one is what keeps Log4Shell out.
|
||||
let logging = &manifest["logging"]["client"]["file"];
|
||||
if !logging.is_null() {
|
||||
game.push(File {
|
||||
url: text(artifact, "url")?,
|
||||
sha1: text(artifact, "sha1")?,
|
||||
size: artifact["size"].as_u64(),
|
||||
url: text(logging, "url")?,
|
||||
sha1: text(logging, "sha1")?,
|
||||
size: logging["size"].as_u64(),
|
||||
path: dir
|
||||
.join("libraries")
|
||||
.join(relative(&text(artifact, "path")?)?),
|
||||
.join("assets/log_configs")
|
||||
.join(relative(&text(logging, "id")?)?),
|
||||
});
|
||||
}
|
||||
log(format!(
|
||||
"Downloading Minecraft {v} from Mojang: the game and {} libraries",
|
||||
game.len() - 1
|
||||
"Downloading Minecraft {v} from Mojang: the game and {libraries} libraries"
|
||||
));
|
||||
fetch_all(&game, jobs, fetch, cancel, log)?;
|
||||
let index = &manifest["assetIndex"];
|
||||
@@ -112,7 +136,7 @@ pub fn download(
|
||||
}
|
||||
|
||||
/// Mojang's name for this system in its Java runtime index.
|
||||
fn java_platform() -> Option<&'static str> {
|
||||
pub(crate) fn java_platform() -> Option<&'static str> {
|
||||
Some(match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => "mac-os-arm64",
|
||||
("macos", "x86_64") => "mac-os",
|
||||
@@ -244,7 +268,7 @@ fn text(v: &Value, key: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// A path from the version file, refused if it could leave the folder.
|
||||
fn relative(path: &str) -> Result<PathBuf, String> {
|
||||
pub(crate) fn relative(path: &str) -> Result<PathBuf, String> {
|
||||
let safe = !path.contains('\\')
|
||||
&& Path::new(path)
|
||||
.components()
|
||||
@@ -265,6 +289,19 @@ fn allowed(library: &Value, os: &str) -> bool {
|
||||
.is_some_and(|rule| rule["action"] == "allow")
|
||||
}
|
||||
|
||||
/// The classifier holding a library's natives for `os` (`natives-osx`), from its `natives` map;
|
||||
/// `${arch}` is this system's word size, as old versions name Windows' natives.
|
||||
pub(crate) fn native_classifier(library: &Value, os: &str) -> Option<String> {
|
||||
let arch = if cfg!(target_pointer_width = "64") {
|
||||
"64"
|
||||
} else {
|
||||
"32"
|
||||
};
|
||||
library["natives"][os]
|
||||
.as_str()
|
||||
.map(|key| key.replace("${arch}", arch))
|
||||
}
|
||||
|
||||
/// The asset index's objects, once each.
|
||||
fn objects(index: &Value, dir: &Path) -> Result<Vec<File>, String> {
|
||||
let objects = index["objects"]
|
||||
|
||||
@@ -8,6 +8,7 @@ const COMMON: &[u8] = b"a library every system uses";
|
||||
const LINUX: &[u8] = b"a linux native";
|
||||
const SOUND: &[u8] = b"a fake ogg";
|
||||
const LANG: &[u8] = b"{}";
|
||||
const LOGGING: &[u8] = b"<Configuration/>";
|
||||
/// Never cancelled.
|
||||
static GO: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -456,3 +457,113 @@ fn a_cancelled_download_goes_on_where_it_stopped() {
|
||||
assert_eq!(asked.len(), 6, "{asked:#?}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A version Horizon doesn't pin, as Mojang's list names it (its version file's sha1): the
|
||||
/// client's sha1 comes from that checked file, and an old version's natives for this system
|
||||
/// (a classifier of their library, `${arch}` filled in) come with the libraries.
|
||||
#[test]
|
||||
fn any_listed_version_comes_with_its_natives() {
|
||||
let native = |name: &str, data: &[u8]| {
|
||||
serde_json::json!({
|
||||
"path": format!("org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"),
|
||||
"sha1": sha1_hex(data),
|
||||
"url": format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar"),
|
||||
})
|
||||
};
|
||||
let index = serde_json::json!({"objects": {}}).to_string();
|
||||
let manifest = serde_json::json!({
|
||||
"id": "1.12.2",
|
||||
"assetIndex": {"id": "1.12", "sha1": sha1_hex(index.as_bytes()),
|
||||
"url": "https://piston-meta.mojang.com/v1/packages/i/1.12.json"},
|
||||
"downloads": {"client": {"sha1": sha1_hex(CLIENT), "size": CLIENT.len(), "url": "https://piston-data.mojang.com/old.jar"}},
|
||||
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {
|
||||
"id": "client-1.12.xml", "sha1": sha1_hex(LOGGING), "size": LOGGING.len(),
|
||||
"url": "https://piston-data.mojang.com/client-1.12.xml"}}},
|
||||
"libraries": [{
|
||||
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
|
||||
"natives": {"osx": "natives-osx", "windows": "natives-windows-${arch}"},
|
||||
"downloads": {"classifiers": {
|
||||
"natives-osx": native("natives-osx", b"osx natives"),
|
||||
"natives-windows-64": native("natives-windows-64", b"win64 natives"),
|
||||
"natives-windows-32": native("natives-windows-32", b"win32 natives"),
|
||||
}},
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
let sha1 = sha1_hex(manifest.as_bytes());
|
||||
let lib = |name: &str| format!("https://libraries.minecraft.net/lwjgl-platform-{name}.jar");
|
||||
let server = Server {
|
||||
files: HashMap::from([
|
||||
(
|
||||
format!("https://piston-meta.mojang.com/v1/packages/{sha1}/1.12.2.json"),
|
||||
manifest.into_bytes(),
|
||||
),
|
||||
(
|
||||
"https://piston-data.mojang.com/old.jar".into(),
|
||||
CLIENT.to_vec(),
|
||||
),
|
||||
(
|
||||
"https://piston-data.mojang.com/client-1.12.xml".into(),
|
||||
LOGGING.to_vec(),
|
||||
),
|
||||
(lib("natives-osx"), b"osx natives".to_vec()),
|
||||
(lib("natives-windows-64"), b"win64 natives".to_vec()),
|
||||
(lib("natives-windows-32"), b"win32 natives".to_vec()),
|
||||
(
|
||||
"https://piston-meta.mojang.com/v1/packages/i/1.12.json".into(),
|
||||
index.into_bytes(),
|
||||
),
|
||||
]),
|
||||
asked: Mutex::new(Vec::new()),
|
||||
};
|
||||
let pins = Pins::for_version("1.12.2", &sha1);
|
||||
let platform = |os: &str| {
|
||||
let dir = scratch(&format!("any-{os}"));
|
||||
download(&pins, &dir, os, 2, &|url| server.fetch(url), &GO, &|_| {}).unwrap();
|
||||
dir
|
||||
};
|
||||
let mac = platform("osx");
|
||||
assert_eq!(
|
||||
fs::read(mac.join("versions/1.12.2/1.12.2.jar")).unwrap(),
|
||||
CLIENT
|
||||
);
|
||||
let natives = |dir: &Path, name: &str| {
|
||||
dir.join(format!(
|
||||
"libraries/org/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-{name}.jar"
|
||||
))
|
||||
};
|
||||
assert!(natives(&mac, "natives-osx").is_file());
|
||||
assert_eq!(
|
||||
fs::read(mac.join("assets/log_configs/client-1.12.xml")).unwrap(),
|
||||
LOGGING
|
||||
);
|
||||
assert!(!natives(&mac, "natives-windows-64").exists());
|
||||
let windows = platform("windows");
|
||||
assert!(natives(&windows, "natives-windows-64").is_file());
|
||||
assert!(!natives(&windows, "natives-windows-32").exists());
|
||||
// Linux has no natives in this library: nothing to fetch for it, and no error.
|
||||
let linux = platform("linux");
|
||||
assert!(!linux.join("libraries").exists());
|
||||
assert!(linux.join("assets/log_configs/client-1.12.xml").is_file());
|
||||
for dir in [mac, windows, linux] {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
// A client that isn't the one its checked version file names is refused.
|
||||
let wrong = Server {
|
||||
files: server
|
||||
.files
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(url, body)| match url.ends_with("old.jar") {
|
||||
true => (url, b"another jar".to_vec()),
|
||||
false => (url, body),
|
||||
})
|
||||
.collect(),
|
||||
asked: Mutex::new(Vec::new()),
|
||||
};
|
||||
let dir = scratch("any-wrong");
|
||||
let refused = download(&pins, &dir, "osx", 1, &|url| wrong.fetch(url), &GO, &|_| {});
|
||||
assert!(refused.unwrap_err().contains("old.jar"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
+318
-18
@@ -5,7 +5,10 @@
|
||||
//! player's rights.
|
||||
//!
|
||||
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
|
||||
//! every mod of one build must pin the same one. The build compiles that revision's source, from
|
||||
//! every mod of one build must pin the same one. A mod that hooks only Horizon's native hook points
|
||||
//! (`game/native_hooks.rs`) names the version it needs instead (`hooks`): it builds on any Horizon
|
||||
//! whose hook points have that version or a later one (they are only ever added to), the player's own
|
||||
//! by default, so such mods always build together. The build compiles that revision's source, from
|
||||
//! GitHub, never a checkout on this computer, and installs as its own version,
|
||||
//! `native-<revision>-<key>` (the key hashes the revision, the mods' files, the tool's commit and
|
||||
//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a
|
||||
@@ -106,6 +109,9 @@ pub struct Bundle {
|
||||
pub horizon: Option<String>,
|
||||
/// The horizon-mixins commit it was tested with (its `.hnmod`'s).
|
||||
pub mixins: Option<String>,
|
||||
/// The native hook points' version it needs (its `.hnmod`'s `hooks`): then `horizon` is the
|
||||
/// revision it was tested on, and it builds on any with these hook points or later ones.
|
||||
pub hooks: Option<u32>,
|
||||
}
|
||||
|
||||
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
|
||||
@@ -143,9 +149,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let native: serde_json::Value =
|
||||
let native_json: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let field = |name: &str| native[name].as_str().map(str::to_owned);
|
||||
let field = |name: &str| native_json[name].as_str().map(str::to_owned);
|
||||
let id = field("id").ok_or_else(|| format!("{} has no id.", path.display()))?;
|
||||
let found = bundle(&dir.join("mixins").join(&id), field("mixins"))?;
|
||||
match (&found.horizon, field("horizon")) {
|
||||
@@ -154,6 +160,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
|
||||
)),
|
||||
(_, native) => Ok(Some(Bundle {
|
||||
horizon: found.horizon.clone().or(native),
|
||||
hooks: native_json["hooks"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
..found
|
||||
})),
|
||||
}
|
||||
@@ -181,6 +190,7 @@ fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
|
||||
dir: dir.to_owned(),
|
||||
horizon: field("compatibility", "horizon_revision"),
|
||||
mixins,
|
||||
hooks: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +233,90 @@ fn agreed(
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// Where Horizon declares its native hook points' version, and the line that does.
|
||||
const HOOKS_FILE: &str = "crates/horizon-client/src/game/native_hooks.rs";
|
||||
const HOOKS_LINE: &str = "pub(crate) const NATIVE_HOOKS_API: u32 = ";
|
||||
|
||||
/// The native hook points' version in `HOOKS_FILE`'s text.
|
||||
fn hooks_in(source: &str) -> Option<u32> {
|
||||
let line = source.lines().find_map(|l| l.strip_prefix(HOOKS_LINE))?;
|
||||
line.trim_end().strip_suffix(';')?.parse().ok()
|
||||
}
|
||||
|
||||
/// The native hook points' version of Horizon at `revision`, from GitHub (None: it has none).
|
||||
fn hooks_at(revision: &str) -> Option<u32> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/{}/{revision}/{HOOKS_FILE}",
|
||||
backend::REPO
|
||||
);
|
||||
let text = backend::get(&url).ok()?.into_string().ok()?;
|
||||
hooks_in(&text)
|
||||
}
|
||||
|
||||
/// The revision to build `mods` on: `pinned` (the pins' agreement, else the installed version's)
|
||||
/// when every mod naming a hook points' version finds it or a later one there (`at` reads a
|
||||
/// revision's). Else, when nothing pins it, the revision those mods were tested on.
|
||||
fn with_hooks(
|
||||
mods: &[Bundle],
|
||||
pinned: String,
|
||||
free: bool,
|
||||
at: impl Fn(&str) -> Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let hooked: Vec<&Bundle> = mods.iter().filter(|b| b.hooks.is_some()).collect();
|
||||
let fits = |revision: &str| {
|
||||
let found = at(revision);
|
||||
hooked.iter().all(|b| found.is_some() && b.hooks <= found)
|
||||
};
|
||||
if hooked.is_empty() || fits(&pinned) {
|
||||
return Ok(pinned);
|
||||
}
|
||||
let tested: Vec<Bundle> = hooked.iter().map(|b| (*b).clone()).collect();
|
||||
let fallback = agreed(
|
||||
&tested,
|
||||
"Horizon",
|
||||
|b| b.horizon.as_ref(),
|
||||
|| Err(String::new()),
|
||||
);
|
||||
if let (true, Ok(revision)) = (free, fallback) {
|
||||
if fits(&revision) {
|
||||
return Ok(revision);
|
||||
}
|
||||
}
|
||||
let list: Vec<String> = hooked
|
||||
.iter()
|
||||
.map(|b| format!("{} (hook points v{})", b.id, b.hooks.unwrap_or_default()))
|
||||
.collect();
|
||||
Err(format!(
|
||||
"Horizon {} doesn't have the native hook points these mods use: {}. Update Horizon or the mods.",
|
||||
&pinned[..pinned.len().min(7)],
|
||||
list.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// A bundle that names a hook points' version builds on another revision than the one it was
|
||||
/// tested on: its `horizon_revision` (which horizon-mixins checks) becomes the build's.
|
||||
fn repin(bundle: &Bundle, revision: &str) -> Result<(), String> {
|
||||
let path = bundle.dir.join(MANIFEST);
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut value: toml::Table = text
|
||||
.parse()
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if let Some(toml::Value::Table(compat)) = value.get_mut("compatibility") {
|
||||
compat.insert("horizon_revision".into(), revision.into());
|
||||
}
|
||||
let text = toml::to_string(&value).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Whether Horizon's source at `source` has the `mods` feature (the WebAssembly mods' runtime,
|
||||
/// gone from later revisions).
|
||||
fn has_mods_feature(source: &Path) -> bool {
|
||||
let path = source.join("crates/horizon-client/Cargo.toml");
|
||||
let text = fs::read_to_string(path).unwrap_or_default();
|
||||
let value: toml::Table = text.parse().unwrap_or_default();
|
||||
value.get("features").and_then(|f| f.get("mods")).is_some()
|
||||
}
|
||||
|
||||
/// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub.
|
||||
pub fn revision_of(version: &str) -> Result<String, String> {
|
||||
let reference = match version.strip_prefix("nightly-") {
|
||||
@@ -398,6 +492,16 @@ fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
/// `horizon-mixins apply`: the mods in `mods` applied to `source`, into `out`. It runs
|
||||
/// `cargo metadata`, so it gets the launcher's toolchain like a build does: on a computer with no
|
||||
/// Rust of its own, a plain command found no cargo (E_METADATA).
|
||||
fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) -> Command {
|
||||
let mut cmd = rust.command(tool);
|
||||
cmd.arg("apply").arg("--workspace").arg(source);
|
||||
cmd.arg("--mods").arg(mods).arg("--out").arg(out);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in
|
||||
/// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly
|
||||
/// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out:
|
||||
@@ -488,12 +592,19 @@ pub fn build(
|
||||
..b
|
||||
})
|
||||
.collect();
|
||||
let revision = agreed(
|
||||
&mods,
|
||||
// A mod naming a hook points' version doesn't pin: it follows the others, else the game.
|
||||
let pins: Vec<Bundle> = mods.iter().filter(|b| b.hooks.is_none()).cloned().collect();
|
||||
let free = pins.iter().all(|b| b.horizon.is_none());
|
||||
let pinned = agreed(
|
||||
&pins,
|
||||
"Horizon",
|
||||
|b| b.horizon.as_ref(),
|
||||
|| revision_of(base),
|
||||
)?;
|
||||
let revision = with_hooks(&mods, pinned, free, hooks_at)?;
|
||||
for b in mods.iter().filter(|b| b.hooks.is_some()) {
|
||||
repin(b, &revision)?;
|
||||
}
|
||||
let mixins = agreed(
|
||||
&mods,
|
||||
"horizon-mixins",
|
||||
@@ -513,24 +624,21 @@ pub fn build(
|
||||
let real = plain(root().canonicalize().map_err(|e| e.to_string())?);
|
||||
let fresh = real.join("stage.new");
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let mut apply = Command::new(&tool);
|
||||
apply.arg("apply").arg("--workspace").arg(&source);
|
||||
apply.arg("--mods").arg(&incoming).arg("--out").arg(&fresh);
|
||||
run(apply, "horizon-mixins", |_| {})?;
|
||||
run(
|
||||
apply(&rust, &tool, &source, &incoming, &fresh),
|
||||
"horizon-mixins",
|
||||
|_| {},
|
||||
)?;
|
||||
let stage = root().join("stage");
|
||||
sync(&fresh, &stage)?;
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let target = root().join("target");
|
||||
let mut compile = rust.command(&rust.cargo());
|
||||
compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target);
|
||||
compile.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--profile",
|
||||
PROFILE,
|
||||
"--features",
|
||||
FEATURES,
|
||||
]);
|
||||
compile.args(["build", "--locked", "--profile", PROFILE]);
|
||||
if has_mods_feature(&stage) {
|
||||
compile.args(["--features", FEATURES]);
|
||||
}
|
||||
compile.args(["-p", "horizon-client", "--bin", "horizon-client"]);
|
||||
let mut crates = 0;
|
||||
run(compile, "Compiling the game", |line| {
|
||||
@@ -545,6 +653,7 @@ pub fn build(
|
||||
"base": base,
|
||||
"horizon": revision,
|
||||
"horizon_mixins": mixins,
|
||||
"hooks": mods.iter().find_map(|m| m.hooks),
|
||||
"rust": rust_toolchain::VERSION,
|
||||
"mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::<Vec<_>>(),
|
||||
});
|
||||
@@ -553,7 +662,12 @@ pub fn build(
|
||||
}
|
||||
match instance.filter(|i| i.kind != instances::Kind::Default) {
|
||||
Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?,
|
||||
None => instance_on(&version)?,
|
||||
None => {
|
||||
let native = instance_on(&version)?;
|
||||
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
|
||||
carry_settings(&backend::game_home(), &native.home, &ids)?;
|
||||
native
|
||||
}
|
||||
};
|
||||
prune(&version);
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
@@ -610,6 +724,42 @@ fn instance_on(version: &str) -> Result<Instance, String> {
|
||||
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
|
||||
}
|
||||
|
||||
/// The mods' settings from the game's own home `from` (where their WebAssembly versions kept
|
||||
/// them) into the Native mods instance's `to`, for each mod `to` has none of yet: its storage
|
||||
/// folder (`horizon-mods/.horizon-mod-data/<id>/`) and its keys (`key.<id>.<name>:…` lines of
|
||||
/// `horizon-mods.txt`). Nothing is overwritten.
|
||||
fn carry_settings(from: &Path, to: &Path, ids: &[&str]) -> Result<(), String> {
|
||||
const DATA: &str = "horizon-mods/.horizon-mod-data";
|
||||
const LIST: &str = "horizon-mods.txt";
|
||||
let read = |home: &Path| fs::read_to_string(home.join(LIST)).unwrap_or_default();
|
||||
let (theirs, mut ours) = (read(from), read(to));
|
||||
let before = ours.len();
|
||||
for id in ids {
|
||||
let data = from.join(DATA).join(id);
|
||||
let target = to.join(DATA).join(id);
|
||||
if data.is_dir() && !target.exists() {
|
||||
backend::copy_dir(&data, &target).map_err(|e| format!("{}: {e}", data.display()))?;
|
||||
}
|
||||
let prefix = format!("key.{id}.");
|
||||
let has_keys = ours.lines().any(|l| l.starts_with(&prefix));
|
||||
for line in theirs
|
||||
.lines()
|
||||
.filter(|l| l.starts_with(&prefix) && !has_keys)
|
||||
{
|
||||
if !ours.is_empty() && !ours.ends_with('\n') {
|
||||
ours.push('\n');
|
||||
}
|
||||
ours += line;
|
||||
ours.push('\n');
|
||||
}
|
||||
}
|
||||
if ours.len() != before {
|
||||
fs::create_dir_all(to).map_err(|e| e.to_string())?;
|
||||
fs::write(to.join(LIST), ours).map_err(|e| format!("{LIST}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the native builds other than `keep` that no instance plays. The compiled crates
|
||||
/// (`native/target`) stay, so the next build only compiles what changed.
|
||||
pub fn prune(keep: &str) {
|
||||
@@ -635,6 +785,46 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_mods_instance_gets_the_mods_settings_once() {
|
||||
let (from, to) = (dir("carry-from"), dir("carry-to"));
|
||||
let data = from.join("horizon-mods/.horizon-mod-data");
|
||||
fs::create_dir_all(data.join("zoom")).unwrap();
|
||||
fs::write(data.join("zoom/level"), "8").unwrap();
|
||||
fs::create_dir_all(data.join("other")).unwrap();
|
||||
fs::write(
|
||||
from.join("horizon-mods.txt"),
|
||||
"mod.zoom:true\nkey.zoom.zoom:key.keyboard.c\nkey.other.x:key.keyboard.x\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
to.join("horizon-mods.txt"),
|
||||
"key.minimap.map:key.keyboard.m",
|
||||
)
|
||||
.unwrap();
|
||||
carry_settings(&from, &to, &["zoom", "minimap"]).unwrap();
|
||||
let ours = to.join("horizon-mods/.horizon-mod-data");
|
||||
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "8");
|
||||
assert!(!ours.join("other").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(to.join("horizon-mods.txt")).unwrap(),
|
||||
"key.minimap.map:key.keyboard.m\nkey.zoom.zoom:key.keyboard.c\n"
|
||||
);
|
||||
// Changed in the instance since: kept.
|
||||
fs::write(ours.join("zoom/level"), "4").unwrap();
|
||||
fs::write(
|
||||
from.join("horizon-mods.txt"),
|
||||
"key.zoom.zoom:key.keyboard.v\n",
|
||||
)
|
||||
.unwrap();
|
||||
carry_settings(&from, &to, &["zoom"]).unwrap();
|
||||
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "4");
|
||||
assert!(fs::read_to_string(to.join("horizon-mods.txt"))
|
||||
.unwrap()
|
||||
.contains("key.zoom.zoom:key.keyboard.c"));
|
||||
let _ = (fs::remove_dir_all(&from), fs::remove_dir_all(&to));
|
||||
}
|
||||
|
||||
fn write_bundle(at: &Path, id: &str, helper: &str) {
|
||||
fs::create_dir_all(at).unwrap();
|
||||
let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n");
|
||||
@@ -660,6 +850,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_mixins_finds_the_launchers_cargo() {
|
||||
let rust = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let cmd = apply(
|
||||
&rust,
|
||||
Path::new("/tools/horizon-mixins"),
|
||||
Path::new("/src"),
|
||||
Path::new("/mods"),
|
||||
Path::new("/out"),
|
||||
);
|
||||
let path = cmd
|
||||
.get_envs()
|
||||
.find(|(k, _)| *k == "PATH")
|
||||
.and_then(|(_, v)| v)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::env::split_paths(path).next(),
|
||||
Some(rust.dir.join("bin"))
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"apply",
|
||||
"--workspace",
|
||||
"/src",
|
||||
"--mods",
|
||||
"/mods",
|
||||
"--out",
|
||||
"/out"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_one_bundle_or_a_folder_of_them() {
|
||||
let one = dir("one");
|
||||
@@ -749,6 +974,7 @@ mod tests {
|
||||
dir: PathBuf::new(),
|
||||
horizon: horizon.map(str::to_owned),
|
||||
mixins: None,
|
||||
hooks: None,
|
||||
};
|
||||
let never = || -> Result<String, String> { panic!("a pin wins") };
|
||||
fn get(b: &Bundle) -> Option<&String> {
|
||||
@@ -772,6 +998,80 @@ mod tests {
|
||||
assert!(agreed(&bad, "Horizon", get, never).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_on_the_hook_points_follow_the_game_or_the_pins() {
|
||||
assert_eq!(
|
||||
hooks_in("x\npub(crate) const NATIVE_HOOKS_API: u32 = 3;\n"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(hooks_in("const NATIVE_HOOKS_API: u32 = 3;"), None);
|
||||
let mod_ = |id: &str, horizon: &str, hooks: Option<u32>| Bundle {
|
||||
id: id.into(),
|
||||
version: "1".into(),
|
||||
dir: PathBuf::new(),
|
||||
horizon: Some(horizon.into()),
|
||||
mixins: None,
|
||||
hooks,
|
||||
};
|
||||
let c = "c".repeat(40);
|
||||
// A (the game) has hook points v1, B v2, C none.
|
||||
let at = |r: &str| match r.as_bytes()[0] {
|
||||
b'a' => Some(1),
|
||||
b'b' => Some(2),
|
||||
_ => None,
|
||||
};
|
||||
let zoom = [mod_("zoom", B, Some(1)), mod_("minimap", &c, Some(1))];
|
||||
assert_eq!(
|
||||
with_hooks(&zoom, A.into(), true, at).unwrap(),
|
||||
A,
|
||||
"the game's own"
|
||||
);
|
||||
let tested = [mod_("zoom", A, Some(1))];
|
||||
assert_eq!(
|
||||
with_hooks(&tested, c.clone(), true, at).unwrap(),
|
||||
A,
|
||||
"an older game"
|
||||
);
|
||||
assert_eq!(
|
||||
with_hooks(&tested, B.into(), false, at).unwrap(),
|
||||
B,
|
||||
"v1 mods on v2"
|
||||
);
|
||||
let newer = [mod_("recipes", B, Some(2))];
|
||||
let err = with_hooks(&newer, A.into(), false, at).unwrap_err();
|
||||
assert!(
|
||||
err.contains("aaaaaaa") && err.contains("recipes (hook points v2)"),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(
|
||||
with_hooks(&newer, A.into(), true, at).unwrap(),
|
||||
B,
|
||||
"its tested commit"
|
||||
);
|
||||
let pinned_only = [mod_("veinminer", B, None)];
|
||||
assert_eq!(with_hooks(&pinned_only, B.into(), false, at).unwrap(), B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hooks_bundle_is_read_and_repinned_to_the_build() {
|
||||
let d = dir("hooks");
|
||||
write_native(&d, "zoom", A, B);
|
||||
let json = fs::read_to_string(d.join(NATIVE_MANIFEST)).unwrap();
|
||||
let json = json.replacen('}', ",\"hooks\":1}", 1);
|
||||
fs::write(d.join(NATIVE_MANIFEST), json).unwrap();
|
||||
let found = bundles(&d).unwrap();
|
||||
assert_eq!(
|
||||
(found[0].hooks, found[0].horizon.as_deref()),
|
||||
(Some(1), Some(A))
|
||||
);
|
||||
repin(&found[0], B).unwrap();
|
||||
let again = bundle(&found[0].dir, None).unwrap();
|
||||
assert_eq!(
|
||||
(again.id.as_str(), again.horizon.as_deref()),
|
||||
("zoom", Some(B))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_changes_with_the_revision_and_any_file_of_the_mods() {
|
||||
let d = dir("key");
|
||||
|
||||
@@ -33,6 +33,16 @@ impl Pins {
|
||||
})
|
||||
}
|
||||
|
||||
/// Any version from Mojang's version list, by its id and its version file's sha1 (the
|
||||
/// list's): the client's sha1 then comes from that checked version file.
|
||||
pub fn for_version(id: &str, manifest_sha1: &str) -> Pins {
|
||||
Pins {
|
||||
version: id.to_string(),
|
||||
client_sha1: String::new(),
|
||||
manifests: vec![manifest_sha1.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_accepted(&self, path: &Path) -> bool {
|
||||
sha1_file(path).is_some_and(|sha1| self.manifests.contains(&sha1))
|
||||
}
|
||||
|
||||
@@ -114,8 +114,11 @@ class UntrustedBuildTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.game = (WORKFLOWS / "game-build.yml").read_text()
|
||||
|
||||
def test_only_the_release_is_trusted(self):
|
||||
self.assertIn("trusted: true", (WORKFLOWS / "release.yml").read_text())
|
||||
def test_no_nightly_build_is_trusted(self):
|
||||
# The train only starts `nightly build` (Nightly only since 2026-10-28); it builds nothing itself.
|
||||
release = (WORKFLOWS / "release.yml").read_text()
|
||||
self.assertNotIn("game-build.yml", release)
|
||||
self.assertIn("gh workflow run nightly-build.yml", release)
|
||||
nightly_build = (WORKFLOWS / "nightly-build.yml").read_text()
|
||||
self.assertIn("trusted: false", nightly_build)
|
||||
self.assertNotIn("trusted: true", nightly_build)
|
||||
|
||||
@@ -269,5 +269,34 @@ class ReleaseTrainTests(unittest.TestCase):
|
||||
"`shaderpacks` folder are left where they are.\n", body)
|
||||
|
||||
|
||||
|
||||
|
||||
class NightlyTrainTests(unittest.TestCase):
|
||||
"""Since 2026-10-28 the train decides when a Nightly goes out (release.yml starts nightly-build.yml)."""
|
||||
|
||||
PRS = [{"number": 1, "labels": [], "paths": ["crates/x.rs"]}]
|
||||
|
||||
def test_one_build_at_a_time_and_twelve_a_day(self):
|
||||
self.assertEqual(train.nightly_decision(True, "5 pull requests", self.PRS, None, 3)[:2], (True, "5 pull requests"))
|
||||
release, reason, _ = train.nightly_decision(True, "5", self.PRS, 4242, 3)
|
||||
self.assertFalse(release)
|
||||
self.assertIn("run 4242", reason)
|
||||
release, reason, urgent = train.nightly_decision(True, "5", self.PRS, None, 12)
|
||||
self.assertFalse(release)
|
||||
self.assertFalse(urgent)
|
||||
self.assertIn("12 Nightlies today", reason)
|
||||
self.assertEqual(train.nightly_decision(False, "waiting", self.PRS, None, 0)[:2], (False, "waiting"))
|
||||
|
||||
def test_release_now_and_force_pass_the_cap_but_not_a_running_build(self):
|
||||
urgent = [{"number": 2, "labels": ["release-now"], "paths": ["crates/x.rs"]}]
|
||||
self.assertEqual(train.nightly_decision(True, "release-now: #2", urgent, None, 12), (True, "release-now: #2", True))
|
||||
self.assertTrue(train.nightly_decision(True, "forced", self.PRS, None, 30, force=True)[0])
|
||||
self.assertFalse(train.nightly_decision(True, "forced", self.PRS, 7, 0, force=True)[0])
|
||||
|
||||
def test_the_cap_is_the_nightly_sets(self):
|
||||
from tools.ci import nightly_set
|
||||
self.assertEqual(train.NIGHTLY_CAP, nightly_set.MAX_BUILDS_PER_DAY)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The opt-in Nightly channel (.github/workflows/nightly-build.yml, docs/release-flow.md "Nightly").
|
||||
"""The Nightly, the only build Horizon ships (.github/workflows/nightly-build.yml, docs/release-flow.md "Nightly").
|
||||
|
||||
nightly.py pick [--force] which commit tonight's nightly builds: GitHub outputs and nightly-notes.md
|
||||
nightly.py rotate renames the current `nightly` pre-release to nightly-<its date>, frees the tag
|
||||
@@ -72,9 +72,9 @@ def output(**values: str) -> None:
|
||||
f.write(f"{k}={v}\n")
|
||||
|
||||
|
||||
OPT_IN = ("**Opt-in, not a release.** A Nightly can include pull requests not reviewed yet, picked by trusted "
|
||||
"reviewers: it may break. Turn it on in the launcher's settings; your release install and your worlds stay "
|
||||
"as they are. Report bugs with the version the game shows (\"Nightly <date> · <commit>\").")
|
||||
PLAYERS = ("The launcher installs it by itself; your worlds stay as they are. A Nightly can include pull requests "
|
||||
"the Team picked before they are merged (below). Report bugs with the version the game shows "
|
||||
"(\"Nightly <date> · <commit>\").")
|
||||
|
||||
|
||||
def source_tag(today: str, sha: str) -> str:
|
||||
@@ -96,7 +96,7 @@ def notes(today: str, chosen: Ref, main: Ref, picked: list[dict], left_out: list
|
||||
fixed = [pr for pr in shown if "feature" not in pr["labels"]]
|
||||
built = f"main ({main.sha[:7]})" + (f" with {len(picked)} pull requests merged in" if picked else "")
|
||||
parts = [f"Nightly {today} · {chosen.sha[:7]}: {built}, built from `{chosen.name}` ({chosen.sha}).",
|
||||
source_line(today, chosen.sha), OPT_IN]
|
||||
source_line(today, chosen.sha), PLAYERS]
|
||||
if not shown and not picked:
|
||||
parts.append("Nothing new for players on main since the previous Nightly.")
|
||||
if new:
|
||||
@@ -192,7 +192,7 @@ def build_set(main: Ref, prs: list[dict], left_out: list[str], today: str, force
|
||||
name = "(unchanged)"
|
||||
if build:
|
||||
done = ns.builds_today(today)
|
||||
if len(done) >= ns.MAX_BUILDS_PER_DAY and event != "schedule":
|
||||
if len(done) >= ns.MAX_BUILDS_PER_DAY and event != "schedule" and os.environ.get("NIGHTLY_URGENT") != "true":
|
||||
stop(repo, issue, f"{len(done)} Nightly builds today already, the most a day takes: the next scheduled "
|
||||
"one (20:17 UTC) builds the set.")
|
||||
name = ns.next_build(today, done)
|
||||
|
||||
+57
-5
@@ -6,6 +6,9 @@
|
||||
python3 tools/release_train.py notes --kind game --tag v2026.10.15 --out dist/notes
|
||||
python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json [--blockers] # before publishing
|
||||
|
||||
The game ships only the Nightly since 2026-10-28 (Ludovic): when `decide --kind game` says so, release.yml starts
|
||||
`nightly build` on the commit it decided (tools/ci/nightly.py), and "the last release" is the published Nightly.
|
||||
|
||||
A kind releases when a pull request merged into main since its last published release changed one of its
|
||||
player-facing paths (GAME_PATHS, LAUNCHER_PATHS), once BATCH of them are waiting and the newest merged at least
|
||||
DEBOUNCE ago (a burst of merges becomes one release), or once the oldest has waited MAX_WAIT (a lone fix still ships),
|
||||
@@ -411,6 +414,45 @@ def notes(kind, version, prs, issues, previous=None):
|
||||
return title, "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
# The workflow that builds and publishes a Nightly. NIGHTLY_CAP is tools/ci/nightly_set.py's MAX_BUILDS_PER_DAY: past
|
||||
# it, the train waits for the next UTC day unless a pull request is labelled release-now or the run is forced.
|
||||
NIGHTLY_WORKFLOW = "nightly-build.yml"
|
||||
NIGHTLY_CAP = 12
|
||||
|
||||
|
||||
def published_nightly():
|
||||
"""The published Nightly (the `nightly` pre-release) as {tag_name, published_at}, or None."""
|
||||
out = run("gh", "api", f"repos/{REPO}/releases/tags/nightly", "--jq", "{tag_name, published_at}", check=False)
|
||||
return json.loads(out) if out.strip() else None
|
||||
|
||||
|
||||
def nightly_building():
|
||||
"""The run of `nightly build` under way or waiting, if any: the train starts no second one."""
|
||||
runs = gh_json("run", "list", "--repo", REPO, "--workflow", NIGHTLY_WORKFLOW, "--limit", "10",
|
||||
"--json", "databaseId,status")
|
||||
return next((entry["databaseId"] for entry in runs if entry["status"] in ("queued", "in_progress", "waiting",
|
||||
"pending", "requested")), None)
|
||||
|
||||
|
||||
def nightly_builds_today(today):
|
||||
"""The nightly/build-<date>-<n> branches of `today` (every Nightly build pushes one)."""
|
||||
out = run("git", "ls-remote", "origin", f"refs/heads/nightly/build-{today}-*")
|
||||
return [line for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def nightly_decision(release, reason, prs, building, built_today, force=False, cap=NIGHTLY_CAP):
|
||||
"""The train's decision held back by the Nightly build: one at a time, and at most `cap` a day unless a pull
|
||||
request is labelled release-now or the run is forced. Returns (release, reason, urgent)."""
|
||||
urgent = force or any("release-now" in pr["labels"] for pr in prs if pr["paths"])
|
||||
if not release:
|
||||
return False, reason, urgent
|
||||
if building:
|
||||
return False, f"a Nightly is being built (run {building}): the next decision takes what it leaves", urgent
|
||||
if built_today >= cap and not urgent:
|
||||
return False, f"{built_today} Nightlies today already, the most a day takes: the next goes out after 00:00 UTC", urgent
|
||||
return True, reason, urgent
|
||||
|
||||
|
||||
def gather(kind, head, releases=None):
|
||||
"""The last published release of this kind and the pull requests merged since, with their player-facing
|
||||
paths."""
|
||||
@@ -496,7 +538,12 @@ def main():
|
||||
write_outputs({"release": "false", "reason": result["reason"]})
|
||||
return 0
|
||||
head = run("git", "rev-parse", head).strip()
|
||||
last, prs = gather(args.kind, head)
|
||||
nightly = published_nightly() if args.kind == "game" else None
|
||||
if nightly:
|
||||
run("git", "fetch", "--quiet", "--force", "origin", "refs/tags/nightly:refs/tags/nightly")
|
||||
last, prs = nightly, merged_between("game", "refs/tags/nightly", head)
|
||||
else:
|
||||
last, prs = gather(args.kind, head)
|
||||
if args.command == "notes":
|
||||
version = args.tag or "unreleased"
|
||||
previous = last["tag_name"] if last and last["tag_name"] != version else None
|
||||
@@ -511,21 +558,26 @@ def main():
|
||||
tags = run("git", "tag", "--list").split()
|
||||
line = release_line()
|
||||
prefix = GAME_PREFIX if args.kind == "game" else LAUNCHER_PREFIX
|
||||
new_line = f"{line[0]}.{line[1]}" if line and first_on_line(tags, prefix, line) else None
|
||||
new_line = f"{line[0]}.{line[1]}" if line and first_on_line(tags, prefix, line) and not nightly else None
|
||||
blockers = open_blockers() if args.kind == "game" else []
|
||||
release, reason = decision(args.kind, prs, now, os.environ.get("RELEASES_PAUSED") == "1", args.force, new_line,
|
||||
blockers)
|
||||
if release and args.head and parity and not (passed_on("parity-main", head) and passed_on("checks", head)):
|
||||
release, reason = False, f"waiting for checks and parity-main to pass on {head[:9]}"
|
||||
urgent = False
|
||||
if args.kind == "game":
|
||||
release, reason, urgent = nightly_decision(release, reason, prs, nightly_building(),
|
||||
len(nightly_builds_today(now.date().isoformat())), args.force)
|
||||
minor = any("feature" in pr["labels"] for pr in prs if pr["paths"])
|
||||
tag = next_game_tag(tags, now.date(), line) if args.kind == "game" else next_launcher_tag(tags, minor, line)
|
||||
tag = "nightly" if args.kind == "game" else next_launcher_tag(tags, minor, line)
|
||||
result = {
|
||||
"release": release, "reason": reason, "tag": tag, "head": head,
|
||||
"release": release, "reason": reason, "tag": tag, "head": head, "urgent": urgent,
|
||||
"last": last["tag_name"] if last else None, "last_published": last["published_at"] if last else None,
|
||||
"pull_requests": [{key: pr[key] for key in ("number", "title", "labels", "paths")} for pr in prs],
|
||||
}
|
||||
print(json.dumps(result, indent=1))
|
||||
write_outputs({"release": str(release).lower(), "reason": reason, "tag": tag, "head": head})
|
||||
write_outputs({"release": str(release).lower(), "reason": reason, "tag": tag, "head": head,
|
||||
"urgent": str(urgent).lower()})
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,19 @@ def scripted(s):
|
||||
s.tick(5)
|
||||
s.add('stopall')
|
||||
s.tick(5)
|
||||
# Joining a world: the menu-like track plays, Minecraft.setLevel stops every
|
||||
# sound, and the game music (not replacing) waits its own delay; a respawn
|
||||
# into another dimension then stops the music manager's track too.
|
||||
s.add('start', 11, 'DEFAULT')
|
||||
s.situation(end, 20, 600, 1)
|
||||
s.tick(110)
|
||||
s.add('stopall')
|
||||
s.situation(water, 12000, 24000, 0)
|
||||
s.tick(3, loading=1)
|
||||
s.tick(5)
|
||||
s.add('stopall')
|
||||
s.add('stop')
|
||||
s.tick(5)
|
||||
# Events that cannot play.
|
||||
for event in ODD:
|
||||
s.add('start', 7, 'DEFAULT')
|
||||
|
||||
@@ -120,8 +120,11 @@ class Services:
|
||||
if ok:
|
||||
return self.reply(200, {'id': PLAYER_UUID, 'name': query['username'], 'properties': []})
|
||||
return self.reply(204)
|
||||
with services.lock:
|
||||
services.log.append(('unknown', url.path))
|
||||
# A bare `GET /` is a local tool probing every new port (T3 Code does, on the Mac mini), not
|
||||
# the game: none of the services has a root.
|
||||
if url.path != '/':
|
||||
with services.lock:
|
||||
services.log.append(('unknown', url.path))
|
||||
self.reply(404, {'error': 'NotFound', 'path': url.path})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
Reference in new issue
Block a user