mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266c4576a5 | ||
|
|
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 | ||
|
|
81f339814b | ||
|
|
0590a85d16 | ||
|
|
be6beb6ec7 | ||
|
|
9cdfc5bd44 | ||
|
|
26af55000a | ||
|
|
3782473f52 | ||
|
|
9cbc96ee7b | ||
|
|
af0fef9196 | ||
|
|
bbec03c14f | ||
|
|
3c9bb38e88 | ||
|
|
dc943f95e8 | ||
|
|
312218a47f | ||
|
|
83b5c5ed53 | ||
|
|
578cd262a5 | ||
|
|
868256f1a8 | ||
|
|
1149c92cb8 | ||
|
|
360272fd79 | ||
|
|
1d6d4cf0a0 | ||
|
|
a1bdb0d22d | ||
|
|
930252e2e4 | ||
|
|
7dba3b4d73 | ||
|
|
e0bf5f6599 | ||
|
|
c7504433c0 | ||
|
|
41266ae70b | ||
|
|
40e2fe0608 | ||
|
|
52dc27fa40 | ||
|
|
4e804cd813 | ||
|
|
e2f3d1a85a | ||
|
|
74c2d13d77 | ||
|
|
9ff0d98010 | ||
|
|
42be3018dd | ||
|
|
da9950c75b | ||
|
|
e8cb6ae83b | ||
|
|
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 | ||
|
|
4c4fcedf40 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 | ||
|
|
7e68f1ed32 | ||
|
|
44850bdd55 | ||
|
|
33e81f464e |
No files matched your search
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py --pre-push
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
@@ -192,9 +192,11 @@ jobs:
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
name: nightly build
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
|
||||
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
|
||||
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
|
||||
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
|
||||
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
|
||||
# on. The previous nightly stays a week as nightly-<date>.
|
||||
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
|
||||
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
|
||||
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
@@ -13,11 +14,11 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
|
||||
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
|
||||
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
@@ -56,6 +57,14 @@ on:
|
||||
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
|
||||
type: string
|
||||
default: ''
|
||||
base:
|
||||
description: The commit of main to build on (default main's newest with green checks and parity-main)
|
||||
type: string
|
||||
default: ''
|
||||
urgent:
|
||||
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
@@ -71,6 +80,7 @@ jobs:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
actions: read # main's newest green checks and parity-main runs (the base)
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
@@ -96,6 +106,8 @@ jobs:
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
NIGHTLY_URGENT: ${{ inputs.urgent }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -180,6 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -194,9 +207,17 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: The smoke test drew a world
|
||||
# No release-blocker check: a nightly is how blockers get tested.
|
||||
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
|
||||
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
|
||||
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
|
||||
# only reports what a real run would do (that is how a blocker's fix is tried).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -208,7 +229,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
@@ -249,7 +271,7 @@ jobs:
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
|
||||
+27
-139
@@ -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"
|
||||
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -93,7 +93,7 @@ Commands block until the action is done and reply with what happened; errors are
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
|
||||
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
/// frames go. Paths inside the workspace lose the build machine's prefix.
|
||||
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// The workspace as it was on the machine that built this binary (it need not exist here).
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
// A report names workspace files the same way on every host, whatever
|
||||
// separator the build machine used.
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(manifest);
|
||||
.unwrap_or(&manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(at) = line.strip_prefix("at ") {
|
||||
if let Some(frame) = frames.last_mut() {
|
||||
frame.1 = Some(short_path(at, root).to_owned());
|
||||
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
|
||||
}
|
||||
} else if let Some((n, name)) = line.split_once(": ")
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
|
||||
@@ -300,6 +300,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
|
||||
@@ -122,6 +122,7 @@ mod moved_extras;
|
||||
mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod native_hooks;
|
||||
mod native_shaders;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
@@ -1408,6 +1409,8 @@ struct Game<'a> {
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
dialogs: dialogs::DialogState,
|
||||
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
|
||||
integrated_starting: Option<integrated::Starting>,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -5053,6 +5056,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
@@ -5077,6 +5081,11 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
@@ -5149,6 +5158,7 @@ impl Game<'_> {
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
self.tick_server_mods();
|
||||
let camera_eye = self.camera_eye_height();
|
||||
let stick = self.pad_move();
|
||||
@@ -11573,6 +11583,27 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
// the window doesn't see yet: the title alone, over the menu background.
|
||||
if self.integrated_starting.is_some() {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
let (w, h) = (hud.width, hud.height);
|
||||
horizon_client::ui::menu_background(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
false,
|
||||
);
|
||||
horizon_client::level_loading::draw(&mut hud, &self.gui.atlas, None, None);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
@@ -11793,7 +11824,8 @@ impl Game<'_> {
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
(
|
||||
@@ -12103,7 +12135,8 @@ impl Game<'_> {
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
fn menu_blur(&self) -> Option<f32> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
@@ -12762,7 +12795,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.perf.cancel_world_opening();
|
||||
return;
|
||||
}
|
||||
if (self.pending_entry.is_some() || self.world_loader.is_some())
|
||||
if (self.pending_entry.is_some()
|
||||
|| self.world_loader.is_some()
|
||||
|| self.integrated_starting.is_some())
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
@@ -12909,6 +12944,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Playing
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& !self.mod_wheel(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
@@ -12981,6 +13017,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
// ConnectScreen's Cancel, under the prompts and dialogs above.
|
||||
WindowEvent::MouseInput { state, button, .. } if self.remote_loading() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.connect_screen_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -13381,6 +13423,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.save_title_options();
|
||||
}
|
||||
PhysicalKey::Code(KeyCode::F5) => {
|
||||
if let Some(t) = self.title.as_mut() {
|
||||
t.refresh_servers();
|
||||
}
|
||||
}
|
||||
_ => self.title_edit(event.text.as_deref(), false),
|
||||
}
|
||||
}
|
||||
@@ -13390,6 +13437,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
|
||||
// key.smoothCamera toggles the cinematic camera.
|
||||
if self.screen == Screen::Playing
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -13760,6 +13808,7 @@ impl<'a> Game<'a> {
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -14234,6 +14283,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if !loader.ready() {
|
||||
|
||||
@@ -675,7 +675,7 @@ impl Game<'_> {
|
||||
// unless a client mod owns the command.
|
||||
if self.remote.is_some() {
|
||||
if let Some(command) = message.strip_prefix('/')
|
||||
&& self.mod_command(command)
|
||||
&& self.client_mod_command(command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -684,7 +684,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.mod_command(command) && !self.local_server_mod_command(command) {
|
||||
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
@@ -873,7 +873,7 @@ impl Game<'_> {
|
||||
/// parse; here the server answers it.)
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
if self.client_mod_command(command) {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
@@ -1029,10 +1029,7 @@ impl Game<'_> {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
self.ui.difficulty = self.level.difficulty_id();
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
|
||||
@@ -91,7 +91,7 @@ fn format_double(v: f64) -> String {
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
if self.client_mod_command(command) {
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
@@ -2573,6 +2573,9 @@ impl Game<'_> {
|
||||
)));
|
||||
}
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
|
||||
@@ -343,8 +343,10 @@ impl Game<'_> {
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.mod_key(physical, false, false);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
|
||||
@@ -131,6 +131,8 @@ impl Game<'_> {
|
||||
map.insert("item".into(), save::item_to_nbt(item));
|
||||
}
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
// The sherds and item go to the guests (BlockEntityData).
|
||||
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
|
||||
self.consume_held();
|
||||
self.audio.play(
|
||||
"minecraft:block.decorated_pot.insert",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
@@ -36,14 +36,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
self.native_mouse_event(button, pressed);
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
_ => return,
|
||||
};
|
||||
self.extras.mouse[i] = pressed;
|
||||
// For mods (`input.clicks-per-second`).
|
||||
if pressed && self.mods.running() {
|
||||
// For mods (`input.clicks-per-second`, `native_clicks`).
|
||||
if pressed {
|
||||
let now = self.extras_now();
|
||||
self.extras.cps[i].click(now);
|
||||
}
|
||||
@@ -57,22 +58,17 @@ impl Game<'_> {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.update_minimap();
|
||||
// For mods (`render.frame-stats`).
|
||||
if self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
} else {
|
||||
self.extras.fps.clear();
|
||||
}
|
||||
// For mods (`render.frame-stats`, `native_frame_rate`).
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
self.native_frame(elapsed);
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
let r = self.extras.fps.reading();
|
||||
(r.fps, r.min, r.low)
|
||||
}
|
||||
/// Attack and use clicks in the last second, and whether each is held.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
|
||||
let now = self.extras_now();
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
@@ -135,7 +131,7 @@ impl Game<'_> {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
let m = self.minimap_wanted()?;
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
@@ -146,11 +142,26 @@ impl Game<'_> {
|
||||
};
|
||||
Some((m.corner, m.size, view))
|
||||
}
|
||||
/// The minimap shown: a native mod's (`native_minimap`), else a
|
||||
/// WebAssembly mod's.
|
||||
fn minimap_wanted(&self) -> Option<NativeMinimap> {
|
||||
if let Some(m) = self.native_minimap_now() {
|
||||
return Some(m);
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
Some(NativeMinimap {
|
||||
corner: m.corner,
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
if self.minimap_wanted().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -201,6 +212,9 @@ impl Game<'_> {
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
if let Some(z) = self.native_zoom_now() {
|
||||
return (z.factor, z.smooth, f64::from(z.turn));
|
||||
}
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
@@ -228,9 +242,6 @@ impl Game<'_> {
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let mod_panels = self.mods.has_panels();
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -256,6 +267,7 @@ impl Game<'_> {
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
self.native_hud(batch, &mut corners);
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,14 @@ impl Game<'_> {
|
||||
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
|
||||
})
|
||||
}
|
||||
/// Whether a server player rides `vehicle` (`Entity.isPassenger`: a
|
||||
/// passenger does not use a portal on its own, the vehicle carries it
|
||||
/// across, `Entity.teleportCrossDimension`).
|
||||
pub(super) fn guest_rides(&self, vehicle: u32) -> bool {
|
||||
self.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(vehicle)))
|
||||
}
|
||||
|
||||
/// `Player.rideTick` for a server player, before its moves this tick:
|
||||
/// it stays in its seat, steers its minecart, or gets out.
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(super) struct Parked {
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
pub(super) boats: Vec<boats::Boat>,
|
||||
pub(super) carts: Vec<minecarts::Minecart>,
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
crystals: Vec<dragon::Crystal>,
|
||||
@@ -65,7 +65,7 @@ pub(super) struct Parked {
|
||||
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
pub(super) mobs: Vec<Mob>,
|
||||
/// The entity ids of the level's fence knots (`knot_ids`).
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
@@ -122,6 +122,12 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
@@ -297,6 +303,23 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The entity's memory (its UUID and the compound it was loaded with)
|
||||
/// moves with it from the level not in place to the level in place, so
|
||||
/// it is saved in the other level as it was.
|
||||
pub(super) fn move_entity_memory(&mut self, from: Dim, id: u32) {
|
||||
let Some(p) = self.levels.parked.get_mut(&from) else {
|
||||
return;
|
||||
};
|
||||
let uuid = p.persist.uuids.remove(&id);
|
||||
let raw = p.persist.raw.remove(&id);
|
||||
if let Some(u) = uuid {
|
||||
self.persist.uuids.insert(id, u);
|
||||
}
|
||||
if let Some(r) = raw {
|
||||
self.persist.raw.insert(id, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
@@ -442,6 +465,9 @@ impl Game<'_> {
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
// A pending portal arrival keeps its destination level
|
||||
// running so it can land and tick (the portal ticket).
|
||||
|| !self.arrival_chunks(*d).is_empty()
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
@@ -454,9 +480,17 @@ impl Game<'_> {
|
||||
/// (other than the Overworld) nobody has been in for a while.
|
||||
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
|
||||
let occupied = self.levels_to_tick();
|
||||
// A vehicle waiting there for its rider's arrival in another level.
|
||||
let waiting: Vec<Dim> = self
|
||||
.levels
|
||||
.parked
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|d| self.vehicle_waits_in(*d))
|
||||
.collect();
|
||||
let mut closing = Vec::new();
|
||||
for (dim, p) in &mut self.levels.parked {
|
||||
if occupied.contains(dim) {
|
||||
if occupied.contains(dim) || waiting.contains(dim) {
|
||||
p.idle = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Hook points for native mods (docs/native-mods.md, "Hook points"): empty
|
||||
//! functions the game calls at fixed places, for horizon-mixins bundles to
|
||||
//! hook with HEAD. Hooks that answer take an out parameter (`claimed`,
|
||||
//! `zoom`…) so several mods can hook the same point. A native mod keeps its
|
||||
//! own state in a module of its bundle; the helpers below read and draw what
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! Names, parameters and call sites are kept from release to release, and
|
||||
//! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`].
|
||||
//! A bundle that hooks only these names the API it needs (`hooks`), and the
|
||||
//! launcher builds it on any Horizon whose API is at least that one. A change
|
||||
//! that would break such a bundle is not made.
|
||||
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
|
||||
//! either (`tests::hook_points_stay_hookable`).
|
||||
use super::{Game, Screen, execution_role::ExecutionRole};
|
||||
use horizon_client::{
|
||||
chat::ChatSource,
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
hud::{HudBatch, rgba},
|
||||
keybinds,
|
||||
mod_list::{self, Installed, NativePage},
|
||||
text::Text,
|
||||
};
|
||||
use std::{fs, path::PathBuf, time::Duration};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 1;
|
||||
|
||||
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
|
||||
/// eased through the field of view like the spyglass when `smooth`, and the
|
||||
/// scale of mouse turning.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeZoom {
|
||||
pub(crate) factor: f32,
|
||||
pub(crate) smooth: bool,
|
||||
pub(crate) turn: f32,
|
||||
}
|
||||
|
||||
/// The minimap a native mod shows (`native_minimap`), drawn by Horizon's
|
||||
/// minimap: its corner, its side in GUI pixels, blocks per map pixel,
|
||||
/// turning with the player or north up, round or square.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeMinimap {
|
||||
pub(crate) corner: Corner,
|
||||
pub(crate) size: f32,
|
||||
pub(crate) blocks_per_pixel: f32,
|
||||
pub(crate) rotate: bool,
|
||||
pub(crate) round: bool,
|
||||
}
|
||||
|
||||
/// The player as a native mod reads it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativePlayer {
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
/// Degrees; 0 faces south, 90 west (`Direction.fromYRot`).
|
||||
pub(crate) yaw: f32,
|
||||
pub(crate) pitch: f32,
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
|
||||
pub(crate) dimension: &'static str,
|
||||
}
|
||||
|
||||
/// A native mod as Options > Mods lists it (`native_mods`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeMod {
|
||||
/// Its id: its settings folder and its keys' lines.
|
||||
pub(crate) id: String,
|
||||
/// `Name 1.0`.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
/// Its keys: name, label and default key (`key.keyboard.z`). The page
|
||||
/// lists them first; `native_mod_key` reads the player's choice.
|
||||
pub(crate) keys: Vec<[String; 3]>,
|
||||
}
|
||||
|
||||
/// The mappings `native_mapping` answers for, with the key their default
|
||||
/// binding is (none: a mouse button, attack or use).
|
||||
#[allow(dead_code)]
|
||||
const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
|
||||
("key.forward", Some(KeyCode::KeyW)),
|
||||
("key.left", Some(KeyCode::KeyA)),
|
||||
("key.back", Some(KeyCode::KeyS)),
|
||||
("key.right", Some(KeyCode::KeyD)),
|
||||
("key.jump", Some(KeyCode::Space)),
|
||||
("key.sneak", Some(KeyCode::ShiftLeft)),
|
||||
("key.sprint", Some(KeyCode::ControlLeft)),
|
||||
("key.attack", None),
|
||||
("key.use", None),
|
||||
];
|
||||
|
||||
/// The hook points. Each does nothing in Horizon.
|
||||
impl Game<'_> {
|
||||
/// Once a frame, in a world or not, after Horizon's extras.
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
/// Once a tick, in every `Game`: the window's (in single player, on LAN
|
||||
/// and on servers), and the server's own (`native_window` is false
|
||||
/// there: the single player server thread, a dedicated server).
|
||||
pub(super) fn native_tick(&mut self) {}
|
||||
/// The HUD (not drawn under F1), after the minimap: place a box with
|
||||
/// `corners.place` (boxes stack away from their edge) and draw it with
|
||||
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
|
||||
/// says is taken.
|
||||
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
let _ = (batch, corners);
|
||||
}
|
||||
/// A key or mouse button went down or up, by its vanilla name
|
||||
/// (`key.keyboard.z`, `key.mouse.left`): keys on any screen (check
|
||||
/// `native_playing`), mouse buttons while playing. Repeats are left out.
|
||||
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
|
||||
let _ = (key, pressed);
|
||||
}
|
||||
/// Wheel steps while playing: set `claimed` to keep them from the hotbar.
|
||||
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
|
||||
let _ = (steps, claimed);
|
||||
}
|
||||
/// A command typed or clicked in chat, without its slash, before the
|
||||
/// server and Horizon's commands see it: set `ran` when the mod ran it.
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
let _ = (command, ran);
|
||||
}
|
||||
/// The zoom a native mod asks for, applied while playing (the spyglass's
|
||||
/// own view wins): set `zoom` when it is none yet.
|
||||
pub(super) fn native_zoom(&self, zoom: &mut Option<NativeZoom>) {
|
||||
let _ = zoom;
|
||||
}
|
||||
/// The minimap a native mod shows: set `minimap` when it is none yet.
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
let _ = minimap;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
}
|
||||
/// The buttons of mod `mod_id`'s settings page, under its keys: push
|
||||
/// their labels when it is yours. Asked again after each click.
|
||||
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
let _ = (mod_id, buttons);
|
||||
}
|
||||
/// Button `button` of mod `mod_id`'s settings page was clicked.
|
||||
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
|
||||
let _ = (mod_id, button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
|
||||
/// turned off does nothing (check this first in each hook) until the
|
||||
/// launcher's next build leaves it out.
|
||||
pub(super) fn native_on(&self, mod_id: &str) -> bool {
|
||||
!self.settings.mods.disabled.contains(mod_id)
|
||||
}
|
||||
/// Playing, with nothing over the world: keys and zoom apply.
|
||||
pub(super) fn native_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
}
|
||||
/// This `Game` is the window's, not a server's.
|
||||
pub(super) fn native_window(&self) -> bool {
|
||||
self.options.execution_role == ExecutionRole::Client
|
||||
}
|
||||
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
|
||||
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
|
||||
!self.debug || corner != Corner::TopLeft
|
||||
}
|
||||
/// The width of `text` in GUI pixels.
|
||||
pub(super) fn native_text_width(&self, text: &str) -> f32 {
|
||||
HudBatch::text_width(&self.gui.atlas, text)
|
||||
}
|
||||
/// A rectangle in GUI pixels, `argb` (0x78000000 is the extras' box).
|
||||
pub(super) fn native_fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
|
||||
let [x, y, w, h] = rect;
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a));
|
||||
}
|
||||
/// A line of text at `(x, y)` in GUI pixels, opaque `rgb`.
|
||||
pub(super) fn native_text(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
text: &str,
|
||||
at: [f32; 2],
|
||||
rgb: u32,
|
||||
shadow: bool,
|
||||
) {
|
||||
let [x, y] = at;
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
let color = rgba(r, g, b, 0xFF);
|
||||
batch.text_styled(&self.gui.atlas, &Text::literal(text), x, y, color, shadow);
|
||||
}
|
||||
/// The player, in a world.
|
||||
pub(super) fn native_player(&self) -> Option<NativePlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
Some(NativePlayer {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
dimension: self.dim.id(),
|
||||
})
|
||||
}
|
||||
/// The frame counter: frames in the last second, the slowest frame and
|
||||
/// the 1% low of the last 5 seconds, in frames per second.
|
||||
pub(super) fn native_frame_rate(&self) -> (u32, u32, u32) {
|
||||
self.frame_reading()
|
||||
}
|
||||
/// Attack and use clicks in the last second.
|
||||
pub(super) fn native_clicks(&mut self) -> (u32, u32) {
|
||||
self.mouse_reading().0
|
||||
}
|
||||
/// A vanilla mapping (`key.forward`, `key.jump`, `key.attack`…): whether
|
||||
/// it is held, and its key's label as Controls shows it.
|
||||
pub(super) fn native_mapping(&mut self, name: &str) -> Option<(bool, String)> {
|
||||
let &(_, key) = MAPPINGS.iter().find(|m| m.0 == name)?;
|
||||
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let down = match key {
|
||||
Some(k) => self.keys.contains(&k),
|
||||
None => self.mouse_reading().1[usize::from(name == "key.use")],
|
||||
};
|
||||
Some((down, label))
|
||||
}
|
||||
/// The key the player chose for a mod's key `name`, else `default`
|
||||
/// (`key.keyboard.z`), as the WebAssembly mods' keys were kept
|
||||
/// (`key.<mod id>.<name>` in `horizon-mods.txt`).
|
||||
pub(super) fn native_mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
|
||||
let chosen = self
|
||||
.settings
|
||||
.mods
|
||||
.keys
|
||||
.get(mod_id)
|
||||
.and_then(|k| k.get(name));
|
||||
chosen.map_or(default, String::as_str).to_owned()
|
||||
}
|
||||
/// A line in chat from the client.
|
||||
pub(super) fn native_chat(&mut self, text: &str) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let line = Text::literal(text);
|
||||
let atlas = &self.gui.atlas;
|
||||
self.ui
|
||||
.chat
|
||||
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
|
||||
}
|
||||
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
|
||||
/// where the WebAssembly mod of the same id kept its storage, so a
|
||||
/// player's settings carry over. Not created here.
|
||||
pub(super) fn native_data_dir(&self, mod_id: &str) -> PathBuf {
|
||||
let mods = Self::options_path().with_file_name(mod_list::DIR);
|
||||
mods.join(".horizon-mod-data").join(mod_id)
|
||||
}
|
||||
/// A mod's setting `key` (one file per key in `native_data_dir`).
|
||||
pub(super) fn native_setting(&self, mod_id: &str, key: &str) -> Option<String> {
|
||||
let path = self.native_data_dir(mod_id).join(key);
|
||||
fs::read_to_string(path).ok().map(|v| v.trim().to_owned())
|
||||
}
|
||||
/// Keeps a mod's setting `key`.
|
||||
pub(super) fn native_set_setting(&self, mod_id: &str, key: &str, value: &str) {
|
||||
let dir = self.native_data_dir(mod_id);
|
||||
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// Options > Mods' rows for the native mods, after the others.
|
||||
pub(super) fn list_native_mods(&mut self) {
|
||||
let mut mods = Vec::new();
|
||||
self.native_mods(&mut mods);
|
||||
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
|
||||
// native row stands for it, and alone toggles its `mod.<id>` line.
|
||||
self.settings
|
||||
.mods
|
||||
.installed
|
||||
.retain(|row| row.native.is_none() && !mods.iter().any(|m| m.id == row.id));
|
||||
for m in mods {
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&m.id, &mut buttons);
|
||||
let page = NativePage {
|
||||
keys: m.keys,
|
||||
buttons,
|
||||
};
|
||||
self.settings.mods.installed.push(Installed {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(page),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A click on button `button` of native mod `i`'s page (its index in
|
||||
/// Options > Mods), and the page's labels after it.
|
||||
pub(super) fn native_settings_event(&mut self, i: usize, button: usize) {
|
||||
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.native_settings_click(&id, button);
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&id, &mut buttons);
|
||||
let row = self.settings.mods.installed.get_mut(i);
|
||||
if let Some(page) = row.and_then(|m| m.native.as_mut()) {
|
||||
page.buttons = buttons;
|
||||
}
|
||||
}
|
||||
/// A key event for `native_key`.
|
||||
pub(super) fn native_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat {
|
||||
return;
|
||||
}
|
||||
if let Some(name) = keybinds::key_name(key) {
|
||||
self.native_key(name, pressed);
|
||||
}
|
||||
}
|
||||
/// A mouse button while playing, for `native_key`.
|
||||
pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) {
|
||||
let name = keybinds::mouse_name(button);
|
||||
self.native_key(&name, pressed);
|
||||
}
|
||||
/// Wheel steps while playing: whether a native mod took them.
|
||||
pub(super) fn native_wheel_event(&mut self, steps: f32) -> bool {
|
||||
let mut claimed = false;
|
||||
self.native_wheel(steps, &mut claimed);
|
||||
claimed
|
||||
}
|
||||
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
|
||||
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
|
||||
let mut ran = false;
|
||||
self.native_command(command, &mut ran);
|
||||
ran || self.mod_command(command)
|
||||
}
|
||||
/// The zoom in effect while playing (a native mod's); the spyglass's own
|
||||
/// view wins.
|
||||
pub(super) fn native_zoom_now(&self) -> Option<NativeZoom> {
|
||||
if !self.native_playing() || self.scoping() {
|
||||
return None;
|
||||
}
|
||||
let mut zoom = None;
|
||||
self.native_zoom(&mut zoom);
|
||||
// As the WebAssembly runtime clamped a mod's zoom.
|
||||
let z = zoom?;
|
||||
let factor = finite(z.factor, 1.0, 10.0);
|
||||
(factor > 1.0).then_some(NativeZoom {
|
||||
factor,
|
||||
smooth: z.smooth,
|
||||
turn: if z.turn.is_finite() {
|
||||
z.turn.clamp(0.05, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
})
|
||||
}
|
||||
/// The minimap a native mod shows.
|
||||
pub(super) fn native_minimap_now(&self) -> Option<NativeMinimap> {
|
||||
let mut minimap = None;
|
||||
self.native_minimap(&mut minimap);
|
||||
minimap.map(|m| NativeMinimap {
|
||||
size: finite(m.size, 16.0, 256.0),
|
||||
blocks_per_pixel: finite(m.blocks_per_pixel, 0.5, 4.0),
|
||||
..m
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` within `lo..=hi`; `lo` when it is not a number.
|
||||
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { lo }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
|
||||
/// the hook points and helpers must stay free of both. The launcher
|
||||
/// reads the API line as it is.
|
||||
#[test]
|
||||
fn hook_points_stay_hookable() {
|
||||
let source = include_str!("native_hooks.rs");
|
||||
let code = source.split("#[cfg(test)]").next().unwrap_or_default();
|
||||
assert!(code.contains("\npub(crate) const NATIVE_HOOKS_API: u32 = "));
|
||||
let lines = code.lines().filter(|l| !l.trim_start().starts_with("//"));
|
||||
for line in lines {
|
||||
assert!(!line.contains("cfg"), "a cfg in the hook points: {line}");
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(!line.contains(mac), "a macro in the hook points: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub(super) struct Plan {
|
||||
height: f32,
|
||||
/// Through an End portal: `approximate` is the End's spawn point, or the
|
||||
/// Overworld's shared spawn.
|
||||
end: bool,
|
||||
pub(super) end: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -91,6 +91,10 @@ pub(super) struct Portals {
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
before: HashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
/// The vehicles server players ride that went through a portal this
|
||||
/// tick, in their level, with their entry: they carry their riders
|
||||
/// (`tick_server_travel`).
|
||||
carrying: Vec<(Dim, u32, BlockPos)>,
|
||||
loaded: bool,
|
||||
dirty: bool,
|
||||
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
|
||||
@@ -628,7 +632,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(self.carts.iter().map(|c| c.id))
|
||||
.map(|id| (id, !self.vehicle_occupied(id))),
|
||||
.map(|id| (id, self.vehicle_can_use_portal(id))),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
@@ -645,7 +649,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_portal(id, entry);
|
||||
// `Entity.teleportCrossDimension` carries the passengers: a
|
||||
// vehicle server players ride takes them along.
|
||||
if self.guest_rides(id) {
|
||||
self.portals.carrying.push((self.dim, id, entry));
|
||||
} else {
|
||||
self.send_through_portal(id, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.load`: the saved `PortalCooldown` (an entity that just came
|
||||
@@ -681,8 +691,51 @@ impl Game<'_> {
|
||||
m.alive()
|
||||
&& m.extra.mount.is_none()
|
||||
&& self.riding != Some(m.id)
|
||||
&& (!self.guest_rides(m.id) || self.carries_riders())
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
}
|
||||
self.carries_riders()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// Whether a vehicle can take the server players riding it through a
|
||||
/// portal: only a server moves players between its levels. Elsewhere a
|
||||
/// ridden vehicle stays, rather than leave its rider behind.
|
||||
fn carries_riders(&self) -> bool {
|
||||
self.options.dedicated.is_some()
|
||||
}
|
||||
/// The vehicles that went through a portal in `dim` with server players
|
||||
/// on them.
|
||||
pub(super) fn take_carrying(&mut self, dim: Dim) -> Vec<(u32, BlockPos)> {
|
||||
let (here, other) = std::mem::take(&mut self.portals.carrying)
|
||||
.into_iter()
|
||||
.partition(|(d, ..)| *d == dim);
|
||||
self.portals.carrying = other;
|
||||
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
|
||||
}
|
||||
/// The plan of vehicle `id` through the portal at `entry`: from its own
|
||||
/// position and size (`getPortalDestination(level, vehicle)`).
|
||||
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
|
||||
}
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
return Some(self.entity_plan(entry, b.position, width, height, false));
|
||||
}
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
Some(self.entity_plan(entry, c.position, width, height, false))
|
||||
}
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
@@ -721,7 +774,7 @@ impl Game<'_> {
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| !self.vehicle_occupied(*id))
|
||||
.filter(|(id, ..)| self.vehicle_can_use_portal(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
@@ -920,6 +973,18 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
/// the area loaded without a player.
|
||||
pub(super) fn arrival_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
|
||||
self.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.filter(|a| a.plan.dim == dim)
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
|
||||
@@ -246,6 +246,7 @@ impl Game<'_> {
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
menu::Input,
|
||||
mobs::{Mob, MobType, Who},
|
||||
@@ -115,6 +115,9 @@ pub(super) struct Remote {
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
joined: bool,
|
||||
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
|
||||
/// while here the player is made at the first teleport.
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
@@ -303,6 +306,7 @@ impl Game<'_> {
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
super::world_loading::preload_server_loot(&self.options.resources);
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -453,6 +457,7 @@ impl Game<'_> {
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
joined: false,
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
@@ -1046,6 +1051,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
// A new ClientLevel: Minecraft.setLevel stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
@@ -1148,6 +1155,23 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// A decorated pot is no block entity here: its tag is kept with the chunk.
|
||||
if name == "minecraft:decorated_pot" {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !fields.is_empty()
|
||||
{
|
||||
let mut full = fields.clone();
|
||||
full.insert("id".into(), Tag::String(name.into()));
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
|
||||
// The section is drawn again with the pot's sherds.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(entity) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
@@ -1162,7 +1186,22 @@ impl Game<'_> {
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
|
||||
if let Some((_, mut loaded)) =
|
||||
horizon_client::save::decode_block_entity(&Tag::Compound(full))
|
||||
{
|
||||
// The turn of a vault's item and a trial spawner's mob is the client's
|
||||
// own, not in the tag: an update keeps it (VaultClientData).
|
||||
match (self.world.block_entities.get(&key), &mut loaded) {
|
||||
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1733,6 +1772,7 @@ impl Game<'_> {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.logged_in = true;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
@@ -1753,7 +1793,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
let (music, _) = self.situational_music();
|
||||
self.music.stop_playing(music.as_ref(), &mut self.audio);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = None;
|
||||
@@ -2234,21 +2279,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::TakeItemEntity(p) => {
|
||||
// An orb's sound comes from the server, at the orb.
|
||||
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
|
||||
// the item, whoever collects it (the server sends none). An
|
||||
// orb's sound comes from the server, at the orb; an arrow this
|
||||
// client does not track as an item sounds at this player.
|
||||
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
|
||||
if !orb
|
||||
&& self
|
||||
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
|
||||
let at = match item {
|
||||
Some(d) => Some([d.position.x, d.position.y, d.position.z]),
|
||||
None if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.entity_id == p.player)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
.is_some_and(|r| r.entity_id == p.player) =>
|
||||
{
|
||||
self.eye_position()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !orb && let Some(at) = at {
|
||||
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
|
||||
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
|
||||
}
|
||||
// ItemPickupParticle toward the collector (this player, another one or a mob).
|
||||
let collector = if self
|
||||
@@ -2349,6 +2403,17 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 3 on this player: its death
|
||||
// sound, which Player.playSound plays for this client alone
|
||||
// (the server's goes to everyone else).
|
||||
if p.event == 3
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
&& let Some(pl) = self.player.as_ref()
|
||||
{
|
||||
let at = [pl.position.x, pl.position.y, pl.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
@@ -3540,6 +3605,7 @@ impl Game<'_> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -4619,6 +4685,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
@@ -4626,9 +4693,26 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
|
||||
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
|
||||
/// player has infinite materials.
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
|
||||
let swings = self.swings_started;
|
||||
let done = self.remote_use_item_on(hand);
|
||||
if done
|
||||
&& self.swings_started != swings
|
||||
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
|
||||
&& (Some(after) != before || self.creative)
|
||||
{
|
||||
self.heights.item_used(hand);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
|
||||
/// (the block reacts, or a block item is placed).
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
|
||||
let held = self.with_hand(hand, |g| g.held().cloned());
|
||||
let held_block = held
|
||||
.as_ref()
|
||||
@@ -4800,6 +4884,9 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5002,7 +5089,9 @@ impl Game<'_> {
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
fn remote_use_hands(&mut self) {
|
||||
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
|
||||
if self.remote_use_item(Hand::Main) {
|
||||
self.heights.item_used(Hand::Main);
|
||||
return;
|
||||
}
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
@@ -5014,7 +5103,9 @@ impl Game<'_> {
|
||||
if off_target {
|
||||
return;
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
if self.remote_use_item(Hand::Off) {
|
||||
self.heights.item_used(Hand::Off);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
@@ -5913,26 +6004,42 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
let shown = horizon_client::net::shown_address(&r.session.address);
|
||||
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
batch.text(
|
||||
atlas,
|
||||
line,
|
||||
(w - tw) / 2.0,
|
||||
h / 2.0 - 12.0 + i as f32 * 12.0,
|
||||
if i == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
},
|
||||
true,
|
||||
);
|
||||
if r.joined {
|
||||
// LevelLoadingScreen: the world's terrain comes in.
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
horizon_client::level_loading::draw(batch, atlas, None, None);
|
||||
return true;
|
||||
}
|
||||
let _ = r.joined;
|
||||
let cancel = self.connect_cancel((w, h)).is_some();
|
||||
let status = horizon_client::text::Text::literal(r.status.as_str());
|
||||
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
|
||||
true
|
||||
}
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
self.remote.is_some() && self.player.is_none()
|
||||
}
|
||||
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
|
||||
/// joined through `ConnectScreen`), until the login is done.
|
||||
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
(self.player.is_none() && !r.joined && self.integrated.is_none())
|
||||
.then(|| horizon_client::level_loading::connect_cancel(w, h))
|
||||
}
|
||||
/// A click on `ConnectScreen`: Cancel aborts the connection
|
||||
/// (`connect.aborted`) and goes back. Escape does nothing there
|
||||
/// (`shouldCloseOnEsc` is false).
|
||||
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
|
||||
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.leave_server(None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The server's death message; chat uses the shared ChatComponent.
|
||||
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
|
||||
let Some(r) = &self.remote else { return };
|
||||
|
||||
@@ -93,4 +93,45 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
/// packet brings it.
|
||||
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
|
||||
return;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let ages = match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if ages {
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use super::{Dim, Game, Mob, boats, lan::LanHost, minecarts};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text, world_spawn::PlayerSpawnFinder};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -20,11 +20,17 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
pub(super) struct Trip {
|
||||
conn: u32,
|
||||
dim: Dim,
|
||||
/// The level the player came from: the vehicle it rides waits there
|
||||
/// until the exit is found.
|
||||
source: Dim,
|
||||
target: Vec3,
|
||||
/// Through an End portal.
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
/// The vehicle the player rides, which travels with it
|
||||
/// (`Entity.teleportCrossDimension` carries the passengers).
|
||||
vehicle: Option<u32>,
|
||||
/// Through an End gateway, within the level: its exit, found or built
|
||||
/// on arrival.
|
||||
gateway: Option<GatewayJob>,
|
||||
@@ -40,6 +46,66 @@ pub(super) struct Trip {
|
||||
placed: bool,
|
||||
}
|
||||
|
||||
/// A vehicle a player rides: an animal, a boat or a minecart. It travels
|
||||
/// with the player through a portal (`Entity.teleportCrossDimension` carries
|
||||
/// the passengers, and they ride the vehicle that is recreated in the other
|
||||
/// dimension).
|
||||
trait Rideable {
|
||||
fn id(&self) -> u32;
|
||||
/// The vehicle stands where its rider arrives, with no motion.
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32);
|
||||
}
|
||||
|
||||
impl Rideable for Mob {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Rideable for boats::Boat {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
self.delta_rotation = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Rideable for minecarts::Minecart {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
}
|
||||
}
|
||||
|
||||
/// The vehicle `id` leaves the level it is in, at the point its rider
|
||||
/// arrives. It keeps its entity id, so the ride is never broken.
|
||||
fn take_vehicle<T: Rideable>(list: &mut Vec<T>, id: u32, at: Vec3, yaw: f32) -> Option<T> {
|
||||
let i = list.iter().position(|v| v.id() == id)?;
|
||||
let mut v = list.remove(i);
|
||||
v.arrive(at, yaw);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// `TeleportCommand` into another level for a server's player: where, and
|
||||
/// the rotation it sets (none keeps the player's).
|
||||
pub(super) struct LevelTeleport {
|
||||
@@ -66,6 +132,14 @@ impl Game<'_> {
|
||||
self.server_trips.iter().any(|t| t.conn == conn)
|
||||
}
|
||||
|
||||
/// Whether a vehicle waits in `dim` for its rider to arrive elsewhere:
|
||||
/// that level stays open until it has moved.
|
||||
pub(super) fn vehicle_waits_in(&self, dim: Dim) -> bool {
|
||||
self.server_trips
|
||||
.iter()
|
||||
.any(|t| t.vehicle.is_some() && t.source == dim)
|
||||
}
|
||||
|
||||
/// Per server tick in each level, after the guests' own: the arrivals of
|
||||
/// the trips into this level, then `Entity.handlePortal` for each player
|
||||
/// in it (Nether portals count up their transition time, End portals
|
||||
@@ -76,6 +150,7 @@ impl Game<'_> {
|
||||
if self.options.dedicated.is_none() {
|
||||
return;
|
||||
}
|
||||
self.carry_riders_through_portals(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -101,13 +176,16 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (at, invulnerable, sleeping) = (
|
||||
let (at, invulnerable, sleeping, riding) = (
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.creative(),
|
||||
g.sleeping,
|
||||
g.vehicle.is_some(),
|
||||
);
|
||||
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
// `canUsePortal(false)` is false for a passenger: its vehicle goes
|
||||
// through and carries it (`carry_riders_through_portals`).
|
||||
if !riding && let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
|
||||
@@ -122,7 +200,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if end_portal && !riding {
|
||||
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
|
||||
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
|
||||
@@ -184,6 +262,44 @@ impl Game<'_> {
|
||||
self.start_server_trip(host, conn, plan.dim, Some(plan));
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` of a vehicle server players ride, once through
|
||||
/// a portal (a vehicle's transition time is 0): the riders travel with
|
||||
/// it and ride it again on arrival (`Entity.teleportCrossDimension`), so
|
||||
/// a rider never waits for its own portal delay.
|
||||
fn carry_riders_through_portals(&mut self, host: &mut LanHost) {
|
||||
for (vehicle, entry) in self.take_carrying(self.dim) {
|
||||
let Some(plan) = self.vehicle_plan(vehicle, entry) else {
|
||||
continue;
|
||||
};
|
||||
if !self.may_enter(plan.dim) {
|
||||
continue;
|
||||
}
|
||||
let riders: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.vehicle == Some(vehicle) && !self.on_server_trip(g.conn))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
// Entity.canTeleport: out of the End, every rider has seen the
|
||||
// credits.
|
||||
if plan.end
|
||||
&& self.dim == Dim::End
|
||||
&& riders
|
||||
.iter()
|
||||
.any(|c| !host.hub.guest(*c).is_some_and(|g| g.seen_credits))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// An End portal's exit is found as a player's (its own respawn
|
||||
// point out of the End).
|
||||
let (target, plan) = (plan.dim, (!plan.end).then_some(plan));
|
||||
for conn in riders {
|
||||
self.start_server_trip(host, conn, target, plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Nether portal block a player's box is in.
|
||||
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
|
||||
let w = WIDTH / 2.0 - 1.0e-5;
|
||||
@@ -217,6 +333,15 @@ impl Game<'_> {
|
||||
let g = &host.hub.guests[i];
|
||||
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
let yaw = g.yaw;
|
||||
// The vehicle the player rides travels with it: the player is its
|
||||
// passenger, and the vehicle is recreated in the other dimension,
|
||||
// where they ride it again (`Entity.teleportCrossDimension`). It
|
||||
// keeps its entity id, so the ride is never broken.
|
||||
let vehicle = g.vehicle.filter(|id| {
|
||||
self.mobs.iter().any(|m| m.id == *id)
|
||||
|| self.boats.iter().any(|b| b.id == *id)
|
||||
|| self.carts.iter().any(|c| c.id == *id)
|
||||
});
|
||||
let source = self.dim;
|
||||
let end = plan.is_none();
|
||||
// Coordinates scale by 8 between the Overworld and the Nether; an
|
||||
@@ -254,9 +379,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: target,
|
||||
source,
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
vehicle,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -275,9 +402,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: config.dim,
|
||||
source: self.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
world_spawn: false,
|
||||
@@ -325,9 +454,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
source: self.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: true,
|
||||
@@ -371,9 +502,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: self.dim,
|
||||
source: self.dim,
|
||||
target: destination,
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -474,6 +607,12 @@ impl Game<'_> {
|
||||
_ => (trip.target, yaw),
|
||||
}
|
||||
};
|
||||
// The vehicle the player rode comes with it, and waits for it
|
||||
// where it arrives (`Entity.teleportCrossDimension` carries the
|
||||
// passengers).
|
||||
if let Some(vehicle) = trip.vehicle {
|
||||
self.arrive_vehicle(trip.source, vehicle, position, yaw);
|
||||
}
|
||||
// Player.getDimensionChangingDelay.
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
@@ -485,6 +624,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The vehicle the player rode arrives with it, at the point the player
|
||||
/// arrives: it keeps its entity id, so the ride is never broken
|
||||
/// (`Entity.teleportCrossDimension` recreates the vehicle in the other
|
||||
/// dimension, and its passengers ride it again).
|
||||
fn arrive_vehicle(&mut self, source: Dim, id: u32, at: Vec3, yaw: f32) {
|
||||
let Some(parked) = self.levels.parked_mut(source) else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = take_vehicle(&mut parked.boats, id, at, yaw) {
|
||||
self.boats.push(b);
|
||||
} else if let Some(c) = take_vehicle(&mut parked.carts, id, at, yaw) {
|
||||
self.carts.push(c);
|
||||
} else if let Some(m) = take_vehicle(&mut parked.mobs, id, at, yaw) {
|
||||
self.mobs.push(m);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Its memory (its UUID and the compound it was loaded with) moves
|
||||
// with it, so it is saved in this level as it was in the other.
|
||||
self.move_entity_memory(source, id);
|
||||
// The vehicle that came through a portal does not go back through it
|
||||
// for a while (`Entity.setPortalCooldown`).
|
||||
self.set_portal_cooldown(id);
|
||||
}
|
||||
|
||||
/// The players the command just run sends to another level
|
||||
/// (`ServerPlayer.teleport` from TeleportCommand): a Respawn packet
|
||||
/// keeping all data and the teleport, with no portal sound. The server
|
||||
@@ -511,9 +675,13 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn: t.conn,
|
||||
dim: t.dim,
|
||||
source,
|
||||
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
|
||||
end: false,
|
||||
plan: None,
|
||||
// A command teleports the player alone: it is not a
|
||||
// passenger any more (`ServerPlayer.teleport`).
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -547,3 +715,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The vehicle a player rides travels with it through a portal: it
|
||||
/// leaves the level the player came from, keeps its entity id (so the
|
||||
/// ride is never broken) and stands where its rider arrives
|
||||
/// (`Entity.teleportCrossDimension` carries the passengers, and they
|
||||
/// ride the vehicle that is recreated in the other dimension).
|
||||
#[test]
|
||||
fn the_vehicle_a_player_rides_travels_with_it() {
|
||||
let mut boats = vec![boats::Boat::new(
|
||||
7,
|
||||
Arc::from("oak"),
|
||||
Vec3::new(0.0, 64.0, 0.0),
|
||||
0.0,
|
||||
)];
|
||||
let mut carts: Vec<minecarts::Minecart> = Vec::new();
|
||||
let mut mobs: Vec<Mob> = Vec::new();
|
||||
let at = Vec3::new(1.5, 65.0, -2.5);
|
||||
let boat = take_vehicle(&mut boats, 7, at, 90.0)
|
||||
.expect("the vehicle the player rides travels with it");
|
||||
assert!(
|
||||
boats.is_empty(),
|
||||
"the vehicle left the level the player came from"
|
||||
);
|
||||
assert_eq!(boat.id, 7, "the vehicle keeps its entity id");
|
||||
assert_eq!(boat.position, at, "it stands where its rider arrives");
|
||||
assert_eq!(boat.yaw, 90.0, "it looks where its rider looks");
|
||||
assert_eq!(boat.velocity, Vec3::ZERO, "it comes with no motion");
|
||||
// A vehicle that is not in the level does not travel.
|
||||
assert!(take_vehicle(&mut carts, 9, at, 0.0).is_none());
|
||||
assert!(carts.is_empty());
|
||||
assert!(take_vehicle(&mut mobs, 9, at, 0.0).is_none());
|
||||
assert!(mobs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ impl StartupMenu {
|
||||
}
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
|
||||
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
|
||||
PhysicalKey::Code(KeyCode::Tab) => title.tab(),
|
||||
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => title.confirm_edit(),
|
||||
PhysicalKey::Code(
|
||||
@@ -392,7 +393,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
Event::ModSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,27 @@ pub(super) fn world_data_used() {
|
||||
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
|
||||
}
|
||||
|
||||
/// A server's world opens: the loot tables its first placed block or broken
|
||||
/// container needs (`loot_data`) are read on a helper thread while the
|
||||
/// connection is made, so the game thread never waits on them (~0.6 s on a
|
||||
/// phone). Kept from then on, as a local world keeps them.
|
||||
pub(super) fn preload_server_loot(resources: &std::path::Path) {
|
||||
world_data_used();
|
||||
if horizon_client::structure_blocks::loaded_loot_data().is_some() {
|
||||
return;
|
||||
}
|
||||
let resources = resources.to_owned();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Loot preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: loot preload: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -1807,6 +1807,14 @@ impl Title {
|
||||
_ => self.mode = Mode::Main,
|
||||
}
|
||||
}
|
||||
/// `JoinMultiplayerScreen.keyPressed`: F5 refreshes the server list as
|
||||
/// Refresh does (`refreshServerList`).
|
||||
pub fn refresh_servers(&mut self) {
|
||||
if let Mode::Multiplayer(screen @ crate::multiplayer::Screen::List { .. }) = &mut self.mode
|
||||
{
|
||||
*screen = crate::multiplayer::Screen::list(&self.saves);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn centered(atlas: &HudAtlas, batch: &mut HudBatch, text: &str, y: f32, color: u32) {
|
||||
batch.text(
|
||||
|
||||
@@ -117,6 +117,32 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
|
||||
assert!(f.title.focused_field().unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f5_reloads_the_server_list() {
|
||||
let mut f = Fixture::new();
|
||||
f.click("Multiplayer");
|
||||
let file = crate::multiplayer::servers_file(&f.title.saves);
|
||||
horizon_protocol::servers::save(
|
||||
&file,
|
||||
&[horizon_protocol::servers::ServerEntry::new(
|
||||
"Local",
|
||||
"127.0.0.1:1",
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let listed = |f: &Fixture| match &f.title.mode {
|
||||
Mode::Multiplayer(crate::multiplayer::Screen::List { servers, .. }) => servers.len(),
|
||||
_ => usize::MAX,
|
||||
};
|
||||
assert_eq!(listed(&f), 0);
|
||||
f.title.refresh_servers();
|
||||
assert_eq!(listed(&f), 1);
|
||||
// Only the list refreshes.
|
||||
f.title.mode = Mode::Main;
|
||||
f.title.refresh_servers();
|
||||
assert!(matches!(f.title.mode, Mode::Main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_singleplayer_opens_creation_and_cancel_returns_to_title() {
|
||||
let mut f = Fixture::new();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -124,6 +124,45 @@ pub fn is_nightly(tag: &str) -> bool {
|
||||
tag == NIGHTLY || tag.starts_with("nightly-")
|
||||
}
|
||||
|
||||
/// The version a nightly installs as, from its release title ("Nightly 2026-10-28 · 0c02067"):
|
||||
/// `nightly-2026-10-28-0c02067`. `published` (GitHub's `published_at`) dates a title without one.
|
||||
/// The launcher and `horizon-server` name it the same way, and native mods read the commit back.
|
||||
pub fn nightly_version(title: &str, published: &str) -> String {
|
||||
let mut words = title
|
||||
.strip_prefix("Nightly ")
|
||||
.unwrap_or_default()
|
||||
.split(" · ");
|
||||
let date = words
|
||||
.next()
|
||||
.filter(|d| is_date(d))
|
||||
.unwrap_or_else(|| published.get(..10).unwrap_or(published));
|
||||
let commit = words
|
||||
.next()
|
||||
.filter(|c| !c.is_empty() && c.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
match commit {
|
||||
Some(commit) => format!("{NIGHTLY}-{date}-{commit}"),
|
||||
None => format!("{NIGHTLY}-{date}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The date of a nightly's version or tag (`nightly-2026-10-28-0c02067`, `nightly-2026-10-28`):
|
||||
/// nightlies follow every release and each other by it.
|
||||
pub fn nightly_date(version: &str) -> Option<&str> {
|
||||
let date = version.strip_prefix("nightly-")?.get(..10)?;
|
||||
is_date(date).then_some(date)
|
||||
}
|
||||
|
||||
fn is_date(text: &str) -> bool {
|
||||
text.len() == 10
|
||||
&& text.chars().enumerate().all(|(i, c)| {
|
||||
if i == 4 || i == 7 {
|
||||
c == '-'
|
||||
} else {
|
||||
c.is_ascii_digit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The signed SHA-256 of `asset` in release `tag`, or why the download must be refused.
|
||||
/// `sums` and `signature` are the release's SHA256SUMS and SHA256SUMS.sig, None when it has none.
|
||||
/// A nightly is never a release: its tags are refused here, whatever their signature
|
||||
@@ -389,6 +428,21 @@ mod tests {
|
||||
assert!(is_nightly("nightly-2026-10-27") && !is_nightly("v0.2.81"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nightly_is_named_by_its_date_and_commit() {
|
||||
let v = nightly_version("Nightly 2026-10-28 · 0c02067", "2026-10-28T20:40:00Z");
|
||||
assert_eq!(v, "nightly-2026-10-28-0c02067");
|
||||
assert_eq!(
|
||||
nightly_version("Something else", "2026-10-29T01:00:00Z"),
|
||||
"nightly-2026-10-29"
|
||||
);
|
||||
assert_eq!(nightly_date(&v), Some("2026-10-28"));
|
||||
assert_eq!(nightly_date("nightly-2026-10-27"), Some("2026-10-27"));
|
||||
assert_eq!(nightly_date("nightly"), None);
|
||||
assert_eq!(nightly_date("v0.2.95"), None);
|
||||
assert!(is_nightly(&v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_is_refused() {
|
||||
let (key, sums, signature) = signed("v2026.10.19");
|
||||
|
||||
@@ -305,7 +305,7 @@ impl Shared {
|
||||
let fetched = (|| {
|
||||
let current = install.current().unwrap_or_default();
|
||||
let release = update::release(None)?;
|
||||
if !update::newer(&release.tag, ¤t) {
|
||||
if !update::update_available(&release.tag, ¤t) {
|
||||
return Ok(None);
|
||||
}
|
||||
shared.update.lock().unwrap().busy =
|
||||
|
||||
@@ -401,7 +401,8 @@ fn check_updates(shared: Arc<Shared>) {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
loop {
|
||||
if let (Ok(release), Some(current)) = (update::release(None), install.current()) {
|
||||
let newer = update::newer(&release.tag, ¤t).then_some(release.tag);
|
||||
let newer = update::update_available(&release.tag, ¤t)
|
||||
.then_some(release.tag);
|
||||
let told = shared.update.lock().unwrap().available.clone();
|
||||
if let Some(tag) = newer.as_ref().filter(|t| told.as_ref() != Some(*t)) {
|
||||
note(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Updates and rollbacks (docs/server.md, section 7) for a `horizon-server` installed by install.sh or
|
||||
//! install.ps1: each release in `<dir>/releases/<tag>`, `<dir>/current` pointing at the one in use, and
|
||||
//! `<dir>/previous` naming the one before it. An update downloads the newest stable release from GitHub, checks it
|
||||
//! against the SHA-256 in the release's signed SHA256SUMS (docs/security.md), unpacks it beside the others and
|
||||
//! switches `current`; the supervisor then restarts the game on it and switches back by itself if the new game
|
||||
//! doesn't come up.
|
||||
//! `<dir>/previous` naming the one before it. An update downloads the current Nightly from GitHub (the only build
|
||||
//! Horizon ships since 2026-10-28; GitHub's latest release while there is none), checks it against the SHA-256 in
|
||||
//! its signed SHA256SUMS (docs/security.md), unpacks it beside the others as `nightly-<date>-<commit>` and switches
|
||||
//! `current`; the supervisor then restarts the game on it and switches back by itself if the new game doesn't come
|
||||
//! up.
|
||||
|
||||
use crate::{api::Client, backup, config::Config, engine, supervisor};
|
||||
use serde_json::{Value, json};
|
||||
@@ -222,9 +223,10 @@ fn point(link: &Path, target: &Path) -> std::io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A release on GitHub: its tag and the archive for this computer.
|
||||
/// A release on GitHub: its name here and the archive for this computer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Release {
|
||||
/// The release's tag, or for a Nightly the version it installs as (`nightly-2026-10-28-0c02067`).
|
||||
pub tag: String,
|
||||
pub asset: String,
|
||||
pub url: String,
|
||||
@@ -249,14 +251,21 @@ fn fetch(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// The newest stable release (or `tag`), from GitHub's API, with the SHA-256 its signed checksums
|
||||
/// give for this computer's archive.
|
||||
/// The current Nightly (or `tag`: a release, `nightly` or a dated `nightly-<date>`), from GitHub's
|
||||
/// API, with the SHA-256 its signed checksums give for this computer's archive. With no Nightly
|
||||
/// published, GitHub's latest release.
|
||||
pub fn release(tag: Option<&str>) -> Result<Release, String> {
|
||||
let url = match tag {
|
||||
Some(tag) => format!("https://api.github.com/repos/{REPO}/releases/tags/{tag}"),
|
||||
None => format!("https://api.github.com/repos/{REPO}/releases/latest"),
|
||||
};
|
||||
let text = fetch(&url).map_err(|e| match e.as_str() {
|
||||
let api = |tag: &str| format!("https://api.github.com/repos/{REPO}/releases/tags/{tag}");
|
||||
let text = match tag {
|
||||
Some(tag) => fetch(&api(tag)),
|
||||
None => match fetch(&api(horizon_release_sign::NIGHTLY)) {
|
||||
Err(e) if e == "404" => fetch(&format!(
|
||||
"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
)),
|
||||
found => found,
|
||||
},
|
||||
}
|
||||
.map_err(|e| match e.as_str() {
|
||||
"404" => format!("no release {} on GitHub", tag.unwrap_or("")),
|
||||
_ => e,
|
||||
})?;
|
||||
@@ -310,15 +319,31 @@ fn pick(
|
||||
let sums = attached(horizon_release_sign::SUMS)?;
|
||||
let signature = attached(horizon_release_sign::SIGNATURE)?
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
|
||||
let sha256 = horizon_release_sign::signed_digest(
|
||||
tag,
|
||||
name,
|
||||
sums.as_deref(),
|
||||
signature.as_deref(),
|
||||
keys,
|
||||
)?;
|
||||
// A Nightly is signed for its channel, never for a tag, and installs under its date and commit.
|
||||
let (tag, sha256) = if horizon_release_sign::is_nightly(tag) {
|
||||
let version = horizon_release_sign::nightly_version(
|
||||
release["name"].as_str().unwrap_or_default(),
|
||||
release["published_at"].as_str().unwrap_or_default(),
|
||||
);
|
||||
let sha256 = horizon_release_sign::nightly_digest(
|
||||
name,
|
||||
sums.as_deref(),
|
||||
signature.as_deref(),
|
||||
keys,
|
||||
)?;
|
||||
(version, sha256)
|
||||
} else {
|
||||
let sha256 = horizon_release_sign::signed_digest(
|
||||
tag,
|
||||
name,
|
||||
sums.as_deref(),
|
||||
signature.as_deref(),
|
||||
keys,
|
||||
)?;
|
||||
(tag.to_owned(), sha256)
|
||||
};
|
||||
Ok(Release {
|
||||
tag: tag.to_owned(),
|
||||
tag,
|
||||
asset: name.to_owned(),
|
||||
url: asset["browser_download_url"]
|
||||
.as_str()
|
||||
@@ -332,10 +357,12 @@ fn pick(
|
||||
/// tagged `vYYYY.MM.DD[.N]` (any `v` and numbers: `Dated`), compared number by number; from 0.2 they are
|
||||
/// `vMAJOR.MINOR.PATCH[-PRE]` (`Semver`), and every one of those is newer than every dated one, so
|
||||
/// v0.2.0 follows v2026.10.19.15. A pre-release (`v0.3.0-rc.1`) comes before its release, as in semver.
|
||||
/// Nightlies (`nightly-<date>-<commit>`, the only builds since 2026-10-28) follow every release, by date.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum TagOrder {
|
||||
Dated(Vec<u64>),
|
||||
Semver([u64; 3], Prerelease),
|
||||
Nightly(String),
|
||||
}
|
||||
|
||||
/// A pre-release's place: (is a release, its identifiers). A release has none and follows every
|
||||
@@ -353,6 +380,9 @@ enum Identifier {
|
||||
const FIRST_YEAR: u64 = 1000;
|
||||
|
||||
fn tag_order(tag: &str) -> Option<TagOrder> {
|
||||
if let Some(date) = horizon_release_sign::nightly_date(tag) {
|
||||
return Some(TagOrder::Nightly(date.to_owned()));
|
||||
}
|
||||
let rest = tag.strip_prefix('v')?;
|
||||
let (core, pre) = match rest.split_once('-') {
|
||||
Some((core, pre)) => (core, Some(pre)),
|
||||
@@ -399,6 +429,13 @@ pub fn newer(tag: &str, than: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `tag`, the newest build on GitHub, is an update for a server running `current`: a
|
||||
/// different build that isn't older. Two Nightlies of one day are equal by [`newer`], so the one
|
||||
/// on GitHub is taken.
|
||||
pub fn update_available(tag: &str, current: &str) -> bool {
|
||||
tag != current && tag_order(tag).is_some() && !newer(current, tag)
|
||||
}
|
||||
|
||||
/// Downloads a release's archive into `<root>/downloads/` and checks its SHA-256; the archive's path.
|
||||
pub fn download(install: &Install, release: &Release) -> Result<PathBuf, String> {
|
||||
use sha2::Digest;
|
||||
@@ -599,14 +636,14 @@ pub fn command(dir: &Path, args: &[String]) -> Result<(), String> {
|
||||
json!({
|
||||
"installed": current,
|
||||
"newest": release.tag,
|
||||
"available": newer(&release.tag, ¤t),
|
||||
"available": update_available(&release.tag, ¤t),
|
||||
"previous": install.previous(),
|
||||
})
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if check {
|
||||
if newer(&release.tag, ¤t) {
|
||||
if update_available(&release.tag, ¤t) {
|
||||
println!(
|
||||
"Horizon {} is out; this server runs {current}. `horizon-server update` installs it.",
|
||||
release.tag
|
||||
@@ -619,7 +656,7 @@ pub fn command(dir: &Path, args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if version.is_none() && !newer(&release.tag, ¤t) {
|
||||
if version.is_none() && !update_available(&release.tag, ¤t) {
|
||||
println!("Already on the newest release ({current}).");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -856,6 +893,59 @@ mod tests {
|
||||
assert!(newer("v0.2.0", "v0.3.0-rc..1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nightlies_follow_every_release() {
|
||||
let today = "nightly-2026-10-28-0c02067";
|
||||
assert!(newer(today, "v0.2.95"));
|
||||
assert!(newer(today, "v2026.10.21"));
|
||||
assert!(newer(today, "nightly-2026-10-27-1a2b3c4"));
|
||||
assert!(!newer("v0.2.95", today));
|
||||
assert!(newer(today, "local"));
|
||||
// Two builds of one day: neither is newer, but the one on GitHub is the update.
|
||||
let later = "nightly-2026-10-28-9b772b2";
|
||||
assert!(!newer(later, today) && !newer(today, later));
|
||||
assert!(update_available(later, today));
|
||||
assert!(update_available(today, "v0.2.95"));
|
||||
assert!(!update_available(today, today));
|
||||
assert!(!update_available("v0.2.95", today));
|
||||
assert!(!update_available("nightly-2026-10-27-1a2b3c4", today));
|
||||
assert!(update_available("v0.2.96", "v0.2.95"));
|
||||
assert!(!update_available("v0.2.94", "v0.2.95"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nightly_is_picked_with_its_channel_signature_and_named_by_its_build() {
|
||||
use horizon_release_sign::{SigningKey, sha256_hex, sums_file};
|
||||
let Some(first) = ASSETS.first() else { return };
|
||||
let asset = |name: &str| json!({ "name": name, "browser_download_url": format!("https://example.invalid/{name}") });
|
||||
let (key, _) = SigningKey::generate().unwrap();
|
||||
let sums = sums_file(&[(first.to_string(), sha256_hex(b"server"))]);
|
||||
let serving = |signed_for: &str| {
|
||||
let (sums, signature) = (sums.clone(), key.sign(signed_for, sums.as_bytes()));
|
||||
move |url: &str| -> Result<Vec<u8>, String> {
|
||||
match url.rsplit('/').next() {
|
||||
Some("SHA256SUMS") => Ok(sums.clone().into_bytes()),
|
||||
Some("SHA256SUMS.sig") => Ok(signature.clone().into_bytes()),
|
||||
_ => Err("404".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let nightly = json!({
|
||||
"tag_name": "nightly", "name": "Nightly 2026-10-28 · 0c02067",
|
||||
"published_at": "2026-10-28T20:40:00Z",
|
||||
"assets": [asset(first), asset("SHA256SUMS"), asset("SHA256SUMS.sig")],
|
||||
});
|
||||
let picked = pick(&nightly, serving("nightly"), &[key.public()]).unwrap();
|
||||
assert_eq!(picked.tag, "nightly-2026-10-28-0c02067");
|
||||
assert_eq!(picked.sha256, sha256_hex(b"server"));
|
||||
// A release's signed list served as the Nightly: refused.
|
||||
assert!(pick(&nightly, serving("v0.2.95"), &[key.public()]).is_err());
|
||||
// A Nightly's signed list served as a release: refused.
|
||||
let mut release = nightly.clone();
|
||||
release["tag_name"] = json!("v0.2.95");
|
||||
assert!(pick(&release, serving("nightly"), &[key.public()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_orders_dated_and_semver_releases() {
|
||||
// `prune` sorts with `newer`: semver releases first, then the dated ones.
|
||||
|
||||
+3
-3
@@ -53,8 +53,8 @@ Windows (x64), in PowerShell:
|
||||
irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
|
||||
```
|
||||
|
||||
The script downloads the latest Horizon release for your computer from GitHub, checks it against the SHA-256
|
||||
GitHub publishes, and never asks for sudo or administrator rights. It installs:
|
||||
The script downloads the current Horizon Nightly for your computer from GitHub (the only build Horizon ships;
|
||||
`--version` picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
|
||||
|
||||
- the release in `~/.local/share/horizon/releases/<version>` (Windows: `%LOCALAPPDATA%\Horizon`), with
|
||||
`current` pointing at the one in use;
|
||||
@@ -236,7 +236,7 @@ minutes. It keeps the crash report and gives up after 5 crashes in 10 minutes, a
|
||||
after it starts and every 6 hours after, and says so in its log, in `status` and in the panel; it never
|
||||
updates by itself. `horizon-server update --check` asks once. `horizon-server update`:
|
||||
|
||||
1. downloads the newest release and checks it against the SHA-256 GitHub publishes for it;
|
||||
1. downloads the current Nightly and checks it against its signed SHA-256;
|
||||
2. stops the game, backs up the world (`backups/<time>-before-<release>.zip`) and switches to the new
|
||||
release;
|
||||
3. starts the game on it and waits up to 5 minutes (`--timeout SECONDS`) for it to come up.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -15,7 +15,7 @@ missing; [Rust rewrite status](rust-parity.md) has the details.
|
||||
## Run from a checkout
|
||||
|
||||
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ only for the development
|
||||
harnesses). Horizon never
|
||||
harnesses). A checkout never
|
||||
downloads the game: resource preparation imports **your own copy** (the 26.3
|
||||
client from your official launcher install, and the 26.3 server jar you
|
||||
downloaded yourself; see [Build from source](../README.md#build-from-source)),
|
||||
|
||||
+36
-4
@@ -19,6 +19,19 @@ builds after its Nightly is replaced. All the mods of one build must pin the sam
|
||||
launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no
|
||||
pin builds on the commit of the installed version the build starts from.
|
||||
|
||||
### Mods on the hook points
|
||||
|
||||
A mod that hooks only Horizon's native hook points (`crates/horizon-client/src/game/native_hooks.rs`: empty
|
||||
functions the game calls at fixed places, plus helpers, kept stable across releases) can name the version it
|
||||
needs instead of pinning: `"hooks": 1` in its `.hnmod`'s `horizon-native.json`, the `NATIVE_HOOKS_API` of that
|
||||
file when it was made. Hook points are only ever added: each addition raises `NATIVE_HOOKS_API`, and a mod builds
|
||||
on any Horizon whose version is the one it needs or a later one. Its `horizon` is then only the commit it was tested on. The launcher builds it on the commit the other mods
|
||||
pin, else on the installed version's commit (the game the player plays), after checking that commit's
|
||||
`NATIVE_HOOKS_API` (read from GitHub) is at least the mod's; when it isn't and nothing pins, it builds on the commit the
|
||||
mod was tested on. In its own copy of the mod it sets `horizon_revision` to the commit it builds on. Horizon's
|
||||
official mods (Zoom, FPS counter, Keystrokes, Minimap, Recipes) are made this way, so any of them build together
|
||||
with no re-release for each Nightly. The `mods` feature (WebAssembly mods) is passed to the build only when the source has it.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
|
||||
@@ -32,8 +45,12 @@ trusting native code is the player's decision, never an agent's.
|
||||
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
|
||||
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
|
||||
rustup, nothing on the system; the player's own Rust is never used. Windows uses the `windows-gnu` host, which
|
||||
links without Visual Studio. The linker is the system's on macOS (the Command Line Tools: the launcher asks for
|
||||
`xcode-select --install`) and Linux (`cc`). `horizon-launcher rust status|install|remove`.
|
||||
links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
|
||||
(`rust_toolchain::MINGW`: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT like `rust-mingw`, one zip pinned by
|
||||
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring`, `hidapi` and wasmtime compile C,
|
||||
and `windows-sys`' `raw-dylib` imports need `dlltool` and the assembler it runs. The linker is the system's on
|
||||
macOS (the Command Line Tools: the launcher asks for `xcode-select --install`) and Linux (`cc`).
|
||||
`horizon-launcher rust status|install|remove`.
|
||||
2. **horizon-mixins**, at the commit the mods name (their `.hnmod`'s `mixins`), else `native_mods::MIXINS_REV`,
|
||||
built once with that cargo (`cargo install --locked --git … --rev …`) into `native/tools/<commit>/`.
|
||||
3. **The source** of the pinned commit: GitHub's archive of it, once per commit (`native/source/<commit>/`, about
|
||||
@@ -49,7 +66,10 @@ trusting native code is the player's decision, never an agent's.
|
||||
6. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
|
||||
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
|
||||
saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance).
|
||||
folder built from the command line, or mods of the default home, go to the **Native mods** instance). That
|
||||
instance gets each mod's settings from the default home the first time it plays it (its
|
||||
`horizon-mods/.horizon-mod-data/<id>/` folder and `key.<id>.` lines of `horizon-mods.txt`, where the mod's
|
||||
WebAssembly version kept them); nothing it already has is overwritten.
|
||||
|
||||
## Cache and disk
|
||||
|
||||
@@ -70,6 +90,15 @@ Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on N
|
||||
| Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all |
|
||||
| A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy |
|
||||
|
||||
Measured 2026-10-29 on Windows 11 (Ryzen 7 9800X3D, 16 threads), the same mod and Nightly, an empty `native/target/`
|
||||
(horizon-mixins, the source and the crates were already downloaded):
|
||||
|
||||
| What | Size, time |
|
||||
|---|---|
|
||||
| Rust 1.99.1 and MinGW-w64 (`<launcher data>/rust/`) | 1.7 GB, MinGW-w64 875 MB of it |
|
||||
| Crates in the launcher's Cargo home | 657 MB |
|
||||
| First build: release profile, the whole game | 6 min 57 s of compiling, 8 min 3 s in all; `native/target/` 2.0 GB |
|
||||
|
||||
Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one.
|
||||
|
||||
## Trust
|
||||
@@ -80,6 +109,9 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
|
||||
- **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer
|
||||
has read the source: the `.hnmod` *is* the source (only `.rs` and `.toml`, checked without running anything),
|
||||
pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down.
|
||||
A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
|
||||
are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the
|
||||
player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
|
||||
- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs.
|
||||
- What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper
|
||||
([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)).
|
||||
@@ -95,4 +127,4 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ
|
||||
|
||||
- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes).
|
||||
- A prebuilt horizon-mixins from its CI instead of building it on the player's computer.
|
||||
- Windows and Linux: the toolchains are pinned, but a full game build on them is unproven.
|
||||
- Linux: the toolchain is pinned, but a full game build on it is unproven. Windows builds and plays (above).
|
||||
+92
-118
@@ -2,8 +2,9 @@
|
||||
|
||||
How a change gets from a pull request to players: the checks a pull request passes, how it merges, and the
|
||||
automatic releases that follow. Releases are automatic and frequent so the latest Horizon is always playable.
|
||||
There is one channel ([One channel](#one-channel)): each release is published as the release and GitHub's *latest*
|
||||
once its smoke test passed and no `release-blocker` is open, and launchers and servers install it at once.
|
||||
Since 2026-10-28 Horizon ships only the **Nightly** (Ludovic's decision; the Team proposed it): the batch train builds
|
||||
a Nightly instead of a `vX.Y.Z` release, it is published once its smoke test passed and no `release-blocker` is open,
|
||||
and every launcher and `horizon-server update` install it ([Nightly only](#nightly-only)).
|
||||
|
||||
## Pull request checks
|
||||
|
||||
@@ -177,9 +178,9 @@ which runs them on main a batch of merges at a time, on the Mac mini's runner:
|
||||
the failing harnesses, names the first merge they fail on in the run's summary and comments on its pull request
|
||||
(`🤖 Agent: parity-main found that this merge breaks ...`). Fix it with a pull request as usual; the next
|
||||
run checks from the last green commit again.
|
||||
- Game releases ship only a commit both `checks` and `parity-main` passed on (`tools/release_train.py`, see
|
||||
below), so a broken merge holds releases instead of reaching players. `release.yml` also starts when a
|
||||
parity-main run finishes. A forced release (`force=true`) doesn't wait for parity-main.
|
||||
- Nightlies build only on a commit both `checks` and `parity-main` passed on (`tools/release_train.py`,
|
||||
`tools/ci/nightly.py`, see below), so a broken merge holds them instead of reaching players. `release.yml` also
|
||||
starts when a parity-main run finishes. A forced run (`force=true`) doesn't wait for parity-main.
|
||||
|
||||
A pull request still runs its own parity job when it changes a harness script, an oracle
|
||||
(`mods/horizon/src/test/`), a probe (`crates/*/examples/`, `game/*_fixture.rs`, `game/*_harness.rs`), the runner
|
||||
@@ -188,73 +189,80 @@ A pull request still runs its own parity job when it changes a harness script, a
|
||||
|
||||
## Game releases
|
||||
|
||||
`.github/workflows/release.yml` releases the game by itself. It starts after every green `checks` or `parity-main`
|
||||
run of a push to main (it releases a commit only once both passed on it), every 10 minutes (so a release waiting for merges to settle goes out without another merge), and by hand.
|
||||
`.github/workflows/release.yml` is the batch train: it decides when a new Nightly goes out and starts
|
||||
`nightly build` (`.github/workflows/nightly-build.yml`, [Nightly channel](#nightly-channel)) on the commit it
|
||||
decided, which builds, signs and publishes it. It starts after every green `checks` or `parity-main` run of a push to
|
||||
main, every 10 minutes (so a batch waiting for merges to settle goes out without another merge), and by hand.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pr(["pull request"]) --> checks{"verify<br/>parity"}
|
||||
checks -- green --> automerge["auto-merge<br/>(merge commit)"]
|
||||
automerge --> main(["main"])
|
||||
main --> mainchecks{"checks on main"}
|
||||
checks -- green --> merge["merge<br/>(merge commit)"]
|
||||
merge --> main(["main"])
|
||||
main --> mainchecks{"checks and<br/>parity-main"}
|
||||
mainchecks -- green --> decide{"decide<br/>tools/release_train.py"}
|
||||
cron(["every hour :17"]) --> decide
|
||||
decide -- "player-facing PRs settled 5 min<br/>(or waiting 20 min), or release-now" --> build
|
||||
cron(["every 10 minutes"]) --> decide
|
||||
decide -- "5 player-facing PRs settled 5 min,<br/>the oldest waited 6 h, or release-now" --> nightly["nightly build:<br/>that commit + the reviewers' picks"]
|
||||
decide -- otherwise --> wait(["wait"])
|
||||
subgraph build["build, in parallel"]
|
||||
linux["Linux x86_64<br/>GitHub ubuntu-22.04"]
|
||||
windows["Windows x64<br/>GitHub windows-latest"]
|
||||
mac["macOS arm64<br/>Mac mini runner<br/>+ offscreen game run"]
|
||||
end
|
||||
build --> gate{"gate: the smoke test drew a world,<br/>no release-blocker is open"}
|
||||
nightly --> gate{"smoke test drew a world,<br/>no release-blocker is open"}
|
||||
gate -- no --> nothing(["nothing is published"])
|
||||
gate -- yes --> publish["publish vX.Y.Z as the release and latest<br/>notes from the merged PRs"]
|
||||
gate -- yes --> publish["publish the nightly pre-release<br/>notes: what merged since the last one"]
|
||||
publish --> launcher(["every launcher and server installs it"])
|
||||
publish --> kira(["Kira posts it; the first release<br/>after 20:00 UTC pings, once a day"])
|
||||
publish --> kira(["Kira posts it; the first after<br/>20:00 UTC pings, once a day"])
|
||||
```
|
||||
|
||||
### When it releases
|
||||
|
||||
`tools/release_train.py decide --kind game` looks at the pull requests merged into main (first-parent merge
|
||||
commits) since the last **published** game release:
|
||||
commits) since the commit the published Nightly was built on:
|
||||
|
||||
- A pull request counts when it changed what players run: `crates/` (not `examples/`, `tests/`, `benches/`),
|
||||
`xtask/`, `packaging/`, `Cargo.toml`, `Cargo.lock`, `research-lock.json`, or an exporter `horizon-prepare`
|
||||
compiles (the `mods/horizon/src/test/java/dev/horizonlab/parity/*.java` files its sources name). Markdown never
|
||||
counts.
|
||||
- It releases in batches, so players get a few releases a day rather than one per merge: once 5 pull requests count
|
||||
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one release), or once the oldest has
|
||||
- It goes in batches, so players get a few Nightlies a day rather than one per merge: once 5 pull requests count
|
||||
and the newest of them merged at least 5 minutes ago (a wave of merges becomes one Nightly), or once the oldest has
|
||||
waited 6 hours, however few they are.
|
||||
- A pull request labelled `release-now` (a crash or a blocker) releases at once.
|
||||
- A pull request labelled `release-now` (a crash or a blocker) goes out at once.
|
||||
- A pull request labelled `skip-release` (work players don't see) doesn't count and isn't listed in the notes.
|
||||
- An open issue labelled `release-blocker` holds every game release, forced ones too (`held by release-blocker #N`).
|
||||
- One release at a time (concurrency group `game-release`); a run that finds nothing new does nothing.
|
||||
- A scheduled or hand-started run releases main's newest commit whose `checks` passed; after `checks`, the commit
|
||||
they passed on.
|
||||
- The first release of a new release line (below) goes out at once, without waiting for a pull request.
|
||||
- An open issue labelled `release-blocker` holds every Nightly, forced ones too (`held by release-blocker #N`);
|
||||
`nightly build` checks again right before publishing.
|
||||
- One Nightly build at a time: while one runs or waits, the train starts none. At most 12 builds a UTC day (the
|
||||
reviewers' `/nightly` builds count): past that the train waits for the next day, except for `release-now` and
|
||||
`force=true`.
|
||||
- A scheduled run takes main's newest commit where `checks` and `parity-main` passed; after a run of either, the
|
||||
commit it passed on, once both passed there.
|
||||
|
||||
### Tags
|
||||
### Nightly only
|
||||
|
||||
How releases are numbered, and how installs from before 0.2 reach it: [versioning.md](versioning.md).
|
||||
Ludovic decided on 2026-10-28 that Horizon ships only the Nightly. No `vX.Y.Z` game release is made any more:
|
||||
the last one stays GitHub's *latest*, so launchers and servers from before the switch (which don't read Nightlies)
|
||||
keep a working build until they update themselves. `.github/release-line.txt` still numbers the launcher's releases
|
||||
(`[email protected]`).
|
||||
|
||||
- With a release line in `.github/release-line.txt` (`MAJOR.MINOR`, e.g. `0.2`), a game release is `vMAJOR.MINOR.PATCH`,
|
||||
the patch after the newest release tagged on that line (`v0.2.0` first). A `feature` label doesn't change the line:
|
||||
only that file does, by pull request.
|
||||
- Without the file (every release before 0.2), `vYYYY.MM.DD`, then `.01`, `.02`… the same UTC day.
|
||||
- Every reader (`release_train.py`, the launcher, `horizon-server update`, the website) ranks
|
||||
every `vX.Y.Z` tag above every dated one.
|
||||
- **Launchers** (#3342) install the current `nightly` pre-release as their update, as the version
|
||||
`nightly-<date>-<commit>`, after checking its checksums signed for the Nightly channel. A player on the newest
|
||||
release, on a Nightly or on nothing moves to it at the next check; a version picked by hand or pinned by an
|
||||
instance stays. Worlds are in the game's folder, never in a version's, so nothing touches them. The "Nightly
|
||||
builds" setting is gone. The last week's Nightlies (`nightly-<date>`) and the old releases stay on the Versions
|
||||
page.
|
||||
- **Servers** (#3331): `horizon-server update`, `install.sh` and `install.ps1` follow the Nightly the same way.
|
||||
- **Kira** posts each Nightly in #releases; the bug template asks for the version the game shows.
|
||||
- **Everyone gets the reviewers' picks.** A Nightly is main plus the pull requests the trusted reviewers picked
|
||||
([below](#trusted-reviewers-pick-the-nightlys-pull-requests)), so a pick reaches every player with the next
|
||||
Nightly. `/nightly clear` makes it main alone.
|
||||
|
||||
### Labels
|
||||
|
||||
| Label | Effect |
|
||||
| --- | --- |
|
||||
| `feature` | Listed under **New** (Kira pings the releases role); makes the next launcher release a minor one |
|
||||
| `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle |
|
||||
| `skip-release` | Doesn't trigger a release and isn't listed in the notes |
|
||||
| `feature` | Listed under **New** in the Nightly's and the launcher's notes |
|
||||
| `release-now` | A Nightly as soon as main's checks and parity-main pass, past the batch rule and the day's cap |
|
||||
| `skip-release` | Doesn't trigger a Nightly or a launcher release and isn't listed in the notes |
|
||||
| `run-parity` | Runs the parity job once on a fork's pull request |
|
||||
| `full-parity` | Runs the pull request's parity job on playhorizon's runner only (a change every harness reaches) |
|
||||
| `parity-regression` | Opened by the nightly audit for a harness that fails on main |
|
||||
| `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) |
|
||||
| `release-blocker` | On an issue: no Nightly is published while it is open ([When it releases](#when-it-releases)) |
|
||||
|
||||
Pull requests without `feature` are listed under **Fixed**. Titles are written for players, since they become
|
||||
the notes.
|
||||
@@ -275,51 +283,23 @@ runs the game: GitHub's runners have neither a GPU worth the name nor the game f
|
||||
|
||||
### Notes
|
||||
|
||||
`tools/release_train.py notes` writes them from the same pull requests:
|
||||
`tools/ci/nightly.py pick` writes them: what reached main since the previous Nightly (`### New`, label `feature`,
|
||||
and `### Fixed`, one line per pull request: its title, its number, the issues it closes; `release_train.bullet`),
|
||||
then the picked pull requests (`### Not merged yet`), `### Known issues` (the bullets of
|
||||
[docs/known-issues.md](known-issues.md)), thanks to outside contributors, the source commit and the disclaimer. The
|
||||
title stays `Nightly <date> · <commit>`, which launchers and servers read the version from. Pull request titles
|
||||
are written for players, since they become the notes.
|
||||
|
||||
- the title, `Horizon 0.2.3: <the first pull request titles>` (before 0.2, `Horizon 2026.10.15.01: …`, a day's later releases counting .01, .02…, two digits so GitHub's releases page, which sorts them as text, kept them in order), features first, newest first;
|
||||
- a line's first release (`X.Y.0`) opens with a paragraph that says so; 0.2.0's says releases are numbered from now
|
||||
on and how a server that says it is up to date takes it;
|
||||
- `### New` (label `feature`) and `### Fixed` (the rest), one line per pull request: its title, its number, the
|
||||
issues it closes;
|
||||
- `### Known issues`: the bullets of [docs/known-issues.md](known-issues.md);
|
||||
- thanks to outside contributors, how to install, the disclaimer.
|
||||
|
||||
Kira posts every release once in Discord's #releases. It pings the releases role at most once a day: the first game
|
||||
release published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
|
||||
Kira posts every Nightly once in Discord's #releases. It pings the releases role at most once a day: the first
|
||||
Nightly published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
|
||||
silent (no ping and Discord's @silent flag). Launcher releases post without a ping.
|
||||
|
||||
### One channel
|
||||
|
||||
Since 2026-10-23 there is one release channel, and it is every launcher's default. Since 2026-10-27 (Ludovic's
|
||||
decision, revising 2026-10-23's) players may also turn on [Nightly](#nightly-channel), an opt-in pre-release of main
|
||||
plus pull requests the trusted reviewers picked, not all reviewed yet; nothing about releases changes for anyone who
|
||||
doesn't. Every game release is published
|
||||
as the release, never a pre-release, and marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing
|
||||
promotes it later, the checks that used to gate a promotion run in the release itself, and the publish job
|
||||
(`tools/release_train.py gate`) publishes nothing unless both pass:
|
||||
|
||||
- the release smoke test: the macOS build runs the game offscreen on the Mac mini and keeps its report,
|
||||
`smoke-test.json`, only when the game drew a world. The gate reads the report again before publishing, and the
|
||||
release carries it. A run whose macOS build went to GitHub (`MAC_RUNNER=macos-latest`) has no report, so it
|
||||
publishes nothing;
|
||||
- no open issue labelled `release-blocker`. `decide` checks first, so a blocked train builds nothing; the gate checks
|
||||
again right before publishing, so an issue labelled while the builds ran still holds the release. Only the label
|
||||
and the issue's state count, never its text: whoever opened the issue can edit the text after a maintainer
|
||||
labels it. Close the issue (or remove the label) and the next run releases everything waiting, with notes
|
||||
covering all of it.
|
||||
|
||||
A failed gate fails the run; the next run (10 minutes later) decides again. Nothing older than the latest release is
|
||||
ever published: tags only count up.
|
||||
|
||||
The launcher has no channel setting any more and looks for releases every 30 minutes. The game pre-releases of the
|
||||
old Alpha channel (v0.2.1 on, published before this change) stay listed for anyone who wants one by hand, but no
|
||||
launcher installs them as an update, and `release_train.py` ignores them: the first release of the single channel
|
||||
lists everything since the last release that was not a pre-release (v0.2.0). Launchers from before this change keep
|
||||
updating either way: with Alpha (or 0.17's "Beta builds") on they take every release, pre-release or not; with it
|
||||
off, every release that is not a pre-release, which is now every new one.
|
||||
|
||||
`promote.yml` and `tools/release_channels.py` (the daily promotion at 20:00 UTC) are gone.
|
||||
Before 2026-10-28 game releases were `vX.Y.Z` (numbering: [versioning.md](versioning.md)), each published by this
|
||||
workflow as the release and GitHub's *latest*. From 2026-10-23 there was one channel (the Alpha pre-releases and the
|
||||
daily promotion at 20:00 UTC ended: `promote.yml` and `tools/release_channels.py` are gone), and from 2026-10-27 the
|
||||
Nightly was opt-in beside it. Since 2026-10-28 the one channel is the Nightly ([Nightly only](#nightly-only)).
|
||||
|
||||
### Signed checksums
|
||||
|
||||
@@ -366,39 +346,29 @@ them with a clear message, and players download the launcher again from the webs
|
||||
### Starting one by hand
|
||||
|
||||
```sh
|
||||
gh workflow run release --ref <branch> # dry run: builds everything, writes the notes, publishes nothing
|
||||
gh workflow run release --ref <branch> # dry run: decides and says why, starts nothing
|
||||
gh workflow run release --ref main -f dry_run=false # the usual decision, now
|
||||
gh workflow run release --ref main -f dry_run=false -f force=true # release now, whatever the rules (even while paused)
|
||||
gh workflow run release --ref main -f dry_run=false -f force=true # a Nightly now, whatever the rules (even while paused)
|
||||
```
|
||||
|
||||
A hand-started run on any branch but main is always a dry run. The notes are in the run's summary and its
|
||||
`release-notes` artifact.
|
||||
|
||||
### Pausing
|
||||
|
||||
```sh
|
||||
gh variable set RELEASES_PAUSED --body 1 # no automatic game or launcher releases
|
||||
gh variable delete RELEASES_PAUSED # back on; the next hourly run catches up
|
||||
gh variable set RELEASES_PAUSED --body 1 # no automatic Nightly or launcher releases
|
||||
gh variable delete RELEASES_PAUSED # back on; the next run catches up
|
||||
```
|
||||
|
||||
While paused, runs still decide and say why in their summary; `force=true` still releases, but never past an open
|
||||
`release-blocker`.
|
||||
While paused, runs still decide and say why in their summary; `force=true` still starts a Nightly, but never past an
|
||||
open `release-blocker`. The reviewers' `/nightly` and the 20:17 UTC build still run: a line `paused` in
|
||||
`.github/trusted-reviewers.txt` stops the reviewers' commands.
|
||||
|
||||
### Rolling back
|
||||
|
||||
A bad release is withdrawn by turning it back into a draft:
|
||||
|
||||
```sh
|
||||
gh release edit v0.2.3 --draft
|
||||
```
|
||||
|
||||
To stop the next releases while a problem is fixed, label an issue about it `release-blocker`. For the bad
|
||||
release itself: launchers list only published releases, so the previous version becomes the newest one again, launchers that
|
||||
didn't install the bad one won't, and players who did can pick the previous version in the launcher's version
|
||||
list (`launcher/src/backend.rs` tests `withdrawn_release_is_never_offered`). The tag stays, so the next release
|
||||
that day takes the next number, and its notes start from the last *published* release, so they list the
|
||||
withdrawn release's changes again with the fix. Usually the better way out is forward: revert the pull request,
|
||||
label the revert `release-now`, and a fixed release is out as soon as main's checks pass.
|
||||
To stop the next Nightlies while a problem is fixed, label an issue about it `release-blocker`. The way out is
|
||||
forward: revert the pull request (or `/nightly remove` a pick), label the revert `release-now`, and a fixed Nightly
|
||||
is out as soon as main's checks and parity-main pass. Meanwhile a player can pick an earlier build on the launcher's
|
||||
Versions page (the last week's `nightly-<date>`, or a `vX.Y.Z`), and `horizon-server update --version <tag>` does
|
||||
the same for a server.
|
||||
|
||||
## Launcher releases
|
||||
|
||||
@@ -441,13 +411,15 @@ launcher and the website read: both then look the newest launcher tags up one by
|
||||
|
||||
## Nightly channel
|
||||
|
||||
`.github/workflows/nightly-build.yml` publishes an opt-in build once a night (20:17 UTC) and when a trusted
|
||||
reviewer asks, for players who want fixes before they are released (#2681). It builds main plus the pull requests the
|
||||
trusted reviewers picked, merged in their order. With none picked, it builds the newest `union/<agent>-<name>` branch
|
||||
on origin (main plus the pull requests the contribution gate proved together in one wave) when that union is newer
|
||||
than main's head and holds something main lacks, otherwise main (`tools/ci/nightly.py pick`). A night with nothing
|
||||
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
|
||||
before the gate.
|
||||
`.github/workflows/nightly-build.yml` builds and publishes the Nightly, the only build players get since
|
||||
2026-10-28: when the batch train decides ([Game releases](#game-releases), with the commit as `base`), once a night
|
||||
(20:17 UTC) and when a trusted reviewer asks (#2681). It builds main's newest commit where
|
||||
`checks` and `parity-main` passed, as a release does (or the `base` input), plus the pull requests the trusted
|
||||
reviewers picked, merged in their order; with none picked, that commit alone (`tools/ci/nightly.py pick`). No green
|
||||
commit (main's newest `checks` or `parity-main` run failed) builds nothing. A night with nothing new publishes nothing. Since the Nightly is the version every player installs, a pull request goes in only with an
|
||||
approving review at the exact head that is built, from a trusted reviewer or from Ludovic's account (his own or his
|
||||
agents' review), never its author's (Ludovic, 2026-10-28). A push after the review needs a new one: until then the
|
||||
scheduled build keeps the approved head. `/nightly dry-run` needs no review: it publishes nothing.
|
||||
|
||||
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
|
||||
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
|
||||
@@ -491,7 +463,7 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
/nightly 2640 2642 the set becomes these pull requests, merged onto main in this order; builds now
|
||||
/nightly add 2650 adds one, or takes its newest head again; builds now
|
||||
/nightly remove 2640 drops one; builds now
|
||||
/nightly clear empties the set: back to the proven union or main; builds now
|
||||
/nightly clear empties the set: back to main alone; builds now
|
||||
/nightly status what the set holds
|
||||
/nightly dry-run 2640 builds main + these and signs with a throwaway key; publishes nothing, the set stays
|
||||
```
|
||||
@@ -522,14 +494,16 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
- A one-line pull request removing a login from `.github/trusted-reviewers.txt` revokes it as soon as it merges
|
||||
(the file is his under CODEOWNERS).
|
||||
- A line `paused` in that file stops every reviewer's `/nightly` (and triage commands); his own still work.
|
||||
- Main, the gate and releases don't change: picks reach only players who turned Nightly on.
|
||||
- Main and the gate don't change, but a pick reaches every player with the next Nightly.
|
||||
|
||||
- **Jobs.** `pick` chooses or assembles the commit and writes the notes (each pull request in it with its title,
|
||||
author and who picked it, or for a union the open pull requests it holds beyond main). `build` is
|
||||
- **Jobs.** `pick` assembles the commit and writes the notes: what reached main since the previous Nightly (New and
|
||||
Fixed, as a release's notes), then each picked pull request with its title, author and who picked it, the known
|
||||
issues and the thanks. `build` is
|
||||
`game-build.yml`, the same builds and smoke test as a release, with the version `nightly-<date>` and
|
||||
`HORIZON_NIGHTLY="<date> <short sha>"` built in, macOS signed ad hoc. `sign` signs SHA256SUMS for the channel
|
||||
(the message says `horizon release nightly`, so a nightly's signature never passes for a release's). `publish`
|
||||
checks the signature and the smoke test (not `release-blocker`: a nightly is how blockers get tested), renames the
|
||||
checks the signature, the smoke test and that no `release-blocker` is open (as a release; a dry run only warns, so
|
||||
`/nightly dry-run <pr>` still tries a blocker's fix), renames the
|
||||
previous `nightly` pre-release to `nightly-<its date>`, publishes the new one as the `nightly` pre-release (never
|
||||
GitHub's latest), and deletes the `nightly-<date>` pre-releases and tags older than 7 days, nothing else.
|
||||
- **The signing key never meets the pull requests' code.** Every workflow file and script runs from main's copy (a
|
||||
@@ -543,9 +517,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
|
||||
macOS jobs on the runner that runs pull requests' parity jobs (`horizon-game`), never the release runner
|
||||
(`MAC_RUNNER`), and its Rust cache has its own key and is never saved. A trusted build doesn't use the machine's
|
||||
shared sccache, which pull requests' jobs write (`HORIZON_NO_SCCACHE`, `tools/ci/runner-env.sh`).
|
||||
- **Players.** Releases stay the default and the only channel any launcher follows unless its player turns Nightly
|
||||
on. `release_train.py`, `horizon-server update`, `install.sh` and the launcher's release list skip the tags: none
|
||||
of them is `vX.Y.Z`.
|
||||
- **Players.** Every launcher and `horizon-server update` install the `nightly` pre-release
|
||||
([Nightly only](#nightly-only)). It is never GitHub's latest: the last `vX.Y.Z` stays that, for launchers,
|
||||
servers and `install.sh` from before the switch.
|
||||
|
||||
```sh
|
||||
gh workflow run "nightly build" # now; skips when the pick is the commit already published
|
||||
|
||||
+6
-4
@@ -409,10 +409,12 @@ runtime or new dependency. Server-sent events can replace the polling later if a
|
||||
newer release a minute after it starts and then every 6 hours, and only says so, in its log, in `status`
|
||||
and in the panel (`HORIZON_NO_UPDATE_CHECK=1` turns the check off). `update --check` asks GitHub once.
|
||||
`update`:
|
||||
1. downloads the newest stable release's archive for this computer (on Linux the server build; `--version`
|
||||
picks another, a beta included) and checks it against the release's SHA256SUMS, whose Ed25519 signature
|
||||
must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"): an unsigned
|
||||
or tampered release is refused;
|
||||
1. downloads the current Nightly's archive for this computer (the only build Horizon ships since 2026-10-28;
|
||||
GitHub's latest release while there is none; on Linux the server build; `--version` picks another: a
|
||||
`vX.Y.Z` release, or a `nightly-<date>` of the last 7 days) and checks it against its SHA256SUMS, whose Ed25519
|
||||
signature must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"),
|
||||
signed for the Nightly channel: an unsigned or tampered build is refused. A Nightly installs as
|
||||
`nightly-<date>-<commit>`, newer than every `vX.Y.Z`; another build of the same day is an update too;
|
||||
2. unpacks it into `<dir>/releases/<tag>`, beside the others, with its game files linked to
|
||||
`<dir>/game-files` as the installers do;
|
||||
3. stops the game, backs up the world (`backups/<time>-before-<tag>.zip`), records the release in use in
|
||||
|
||||
@@ -7,6 +7,11 @@ Until 0.2 the game was tagged by date (`v2026.10.19.15`) and the launcher counte
|
||||
From 0.2 both follow [semantic versioning](https://semver.org) and share one release line: **Horizon 0.2.x** is the
|
||||
game and the launcher of the 0.2 line.
|
||||
|
||||
**Since 2026-10-28 the game ships only Nightlies** ([release-flow.md](release-flow.md#nightly-only)): no new `vX.Y.Z`
|
||||
game tag is made, and a game build is named `nightly-<date>-<commit>` (its release title, "Nightly <date> ·
|
||||
<commit>"), which launchers and servers rank after every `vX.Y.Z`. The launcher keeps its `[email protected]` tags on
|
||||
the release line. What follows describes the `vX.Y.Z` game tags as they were.
|
||||
|
||||
## Tags
|
||||
|
||||
| | Game | Launcher |
|
||||
|
||||
+33
-12
@@ -12,8 +12,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
|
||||
## What it does
|
||||
|
||||
- **Versions**: lists the published releases of horizon-rust/horizon, installs the build for
|
||||
this platform into its own folder, and removes it again.
|
||||
- **Versions**: lists the published builds of horizon-rust/horizon (the current Nightly, the last
|
||||
week's, then the releases from before), installs the build for this platform into its own
|
||||
folder, and removes it again.
|
||||
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
|
||||
26.3 server jar from that version's own json, and Java 25) and copies its sounds from your
|
||||
launcher's asset store (`horizon-prepare sounds`; a launcher install without them only
|
||||
@@ -31,7 +32,11 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
system's libraries, the asset index and the assets. Every file must have the size and sha1
|
||||
Mojang lists. The Play page shows how far it is and has Cancel; files already whole are kept,
|
||||
so the next try goes on from there. **From my install** prepares from the player's own launcher
|
||||
instead, and an install that lacks the version falls back to the download. Without an owner,
|
||||
instead, and an install that lacks the version falls back to the download. **Play** on a version
|
||||
not prepared yet does it all for an owner, as the official launcher does: it prepares from the
|
||||
install when there is one, else downloads (with Mojang's Java when no Java 25 is installed), then
|
||||
starts the game. With no install on the computer the Play page offers the download alone and the
|
||||
Minecraft folder row says none is needed. Without an owner,
|
||||
the Play page asks to sign in first. `horizon-launcher prepare --download`.
|
||||
- **Mods**: search Modrinth (CurseForge support is coming soon) for resource
|
||||
packs and modpacks, each marked Works, Partly or Doesn't run in
|
||||
@@ -192,6 +197,12 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
|
||||
**Delete** (to the trash, after a confirmation). From the command line:
|
||||
`horizon-launcher screenshots [list|delete] [--instance <name>]`.
|
||||
- **Java** (for the Minecraft: Java Edition instances to come): the runtime a version's JSON asks for
|
||||
(`javaVersion`: its major exactly, Java 8 before that field), found among Mojang's runtimes Horizon
|
||||
downloaded (`minecraft/runtime/` in its data folder, the one preparing uses) and the Java homes installed
|
||||
on this computer, read from their `release` files without starting them; when none has that major,
|
||||
Mojang's runtime is downloaded; an instance may name its own Java home instead.
|
||||
`horizon-launcher java [list|install]`.
|
||||
- **Multiplayer**: Home lists the selected instance's servers, the game's own Multiplayer list
|
||||
(`servers.dat`, each with the picture the game last saw). **Join** starts the game straight into
|
||||
one; **Add server** and **Remove** (after a confirmation) change the file the game reads,
|
||||
@@ -212,11 +223,16 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
|
||||
when the game starts and brings it back to the front when the game closes; `settings
|
||||
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
|
||||
downloads, and the launcher's own version and update state.
|
||||
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
|
||||
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
|
||||
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
|
||||
by hand, but are never installed as an update. The `update_channel` and `beta_builds` keys
|
||||
older launchers wrote in `settings.json` are ignored and dropped on the next save.
|
||||
- **Updates**: Horizon ships only the Nightly (since 2026-10-28, docs/release-flow.md). The
|
||||
launcher installs the current one (the `nightly` pre-release, checksums signed for the Nightly
|
||||
channel) as `nightly-<date>-<commit>` and selects it when the player was on a Nightly, on the
|
||||
newest release or on nothing; a version picked by hand or pinned by an instance stays. Older
|
||||
Nightlies are removed once nothing plays them. Worlds are never touched: they live in the game's
|
||||
folder, not in a version's. The last week's Nightlies (`nightly-<date>`), the `vX.Y.Z`
|
||||
releases and the old Alpha pre-releases stay on the Versions page to install by hand, never as
|
||||
an update. The "Nightly builds" setting and its `nightly` key are gone (an old key is ignored);
|
||||
its "Nightly" instance stays and keeps following the Nightly. The `update_channel` and
|
||||
`beta_builds` keys older launchers wrote are ignored and dropped on the next save.
|
||||
- **Game arguments**: extra arguments added to the game's command line (for example
|
||||
`--server-thread`), one line where quotes group a word with spaces. They are appended after the
|
||||
launcher's own arguments, kept as `game_args` in `settings.json`, and apply the next time the
|
||||
@@ -244,11 +260,11 @@ minimize, maximize/restore and close; macOS and Linux keep their system title ba
|
||||
|
||||
## Updates
|
||||
|
||||
The launcher looks for a newer Horizon release when it starts and every 30 minutes while it
|
||||
is open, and shows a small "available, Install" line on Play.
|
||||
The launcher looks for a newer Nightly when it starts and every 30 minutes while it is open, and
|
||||
shows a small "available, Install" line on Play.
|
||||
|
||||
It also updates itself from GitHub releases tagged `[email protected]` (Horizon's own are `vX.Y.Z`;
|
||||
neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
|
||||
It also updates itself from GitHub releases tagged `[email protected]` (the game's are `nightly`, and
|
||||
`vX.Y.Z` before 2026-10-28; neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
|
||||
every 0.2 tag counts as newer than all of those (docs/versioning.md). It downloads the new build, replaces its
|
||||
own binary, and offers "Restart" in the footer. Development builds and
|
||||
`HORIZON_LAUNCHER_NO_SELF_UPDATE=1` never replace themselves.
|
||||
@@ -297,6 +313,9 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
|
||||
horizon-launcher account offline play without an account
|
||||
horizon-launcher instance [list] the instances and their game folders (* = Play starts it)
|
||||
horizon-launcher instance create <name> [--copy <instance>] empty, or a copy of one's worlds, options and packs
|
||||
horizon-launcher instance create <name> --minecraft <1.21.4|latest|latest-snapshot> a Minecraft: Java Edition instance
|
||||
horizon-launcher instance versions [--snapshots] [--old] the Java Edition versions, from Mojang's list (kept for offline)
|
||||
horizon-launcher instance java <instance> [--home <dir>|--auto] [--memory <MiB>] [--args "<jvm args>"]
|
||||
horizon-launcher instance rename|icon|group <instance> <value> icon: a block or item name, #rrggbb or none
|
||||
horizon-launcher instance select <instance> make Play start it
|
||||
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
|
||||
@@ -306,6 +325,8 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
|
||||
horizon-launcher packs add <file> | on|off|remove <pack> [--kind shader]
|
||||
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
|
||||
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
|
||||
horizon-launcher java [list] Java runtimes: Mojang's (downloaded by Horizon) and installed ones
|
||||
horizon-launcher java install <21|java-runtime-delta> downloads Mojang's runtime, each file sha1-checked
|
||||
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
|
||||
horizon-launcher multiplayer add <address> [--name <name>] | remove <server>
|
||||
horizon-launcher modpack [list] the imported packs and their coverage
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Preparing…": "Préparation…",
|
||||
"Prepare": "Préparer",
|
||||
"Prepare again": "Préparer à nouveau",
|
||||
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Aucun dossier nécessaire : Horizon télécharge Minecraft depuis Mojang pour votre compte.",
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.",
|
||||
"Ready to play": "Prêt à jouer",
|
||||
"Resources": "Ressources",
|
||||
@@ -157,13 +158,6 @@
|
||||
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.",
|
||||
"Prepared for Minecraft {version}": "Préparé pour Minecraft {version}",
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft.",
|
||||
"Nightly builds": "Versions Nightly",
|
||||
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Peut inclure des pull requests pas encore relues, choisies par des relecteurs de confiance ; peut casser.",
|
||||
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Se joue dans sa propre instance Nightly : vos mondes et la version publiée restent tels quels.",
|
||||
"Installing the nightly, {percent}%": "Installation de la Nightly, {percent} %",
|
||||
"The nightly could not be installed: {error}": "La Nightly n'a pas pu être installée : {error}",
|
||||
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} dans l'instance Nightly. Choisissez Horizon dans la barre latérale pour jouer la version publiée.",
|
||||
"No nightly build yet.": "Pas encore de version Nightly.",
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Cette version du launcher n'a pas de connexion Microsoft (HORIZON_MSA_CLIENT_ID) : elle ne peut pas lancer le jeu.",
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Connectez-vous avec un compte Microsoft qui possède Minecraft: Java Edition pour jouer",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon a besoin d'un compte Microsoft qui possède Minecraft: Java Edition, comme les autres launchers. Il permet aussi de jouer sur les serveurs en ligne avec votre nom et votre skin.",
|
||||
@@ -175,6 +169,7 @@
|
||||
"Open {link} in your browser and enter this code:": "Ouvrez {link} dans votre navigateur et entrez ce code :",
|
||||
"From my install": "Depuis mon installation",
|
||||
"Download and prepare": "Télécharger et préparer",
|
||||
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Pas préparé. Jouer télécharge Minecraft {version} depuis Mojang pour votre compte et le prépare.",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
|
||||
"Cancel": "Annuler",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Preparing…": "Подготовка…",
|
||||
"Prepare": "Подготовить",
|
||||
"Prepare again": "Подготовить заново",
|
||||
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Папка не нужна: Horizon скачает Minecraft у Mojang для вашего аккаунта.",
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.",
|
||||
"Ready to play": "Готово к игре",
|
||||
"Resources": "Ресурсы",
|
||||
@@ -157,13 +158,6 @@
|
||||
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.",
|
||||
"Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}",
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft.",
|
||||
"Nightly builds": "Ночные сборки",
|
||||
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Может включать ещё не проверенные pull request'ы, выбранные доверенными ревьюерами; что-то может сломаться.",
|
||||
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Запускается в отдельной сборке Nightly: ваши миры и релиз остаются как есть.",
|
||||
"Installing the nightly, {percent}%": "Установка ночной сборки, {percent}%",
|
||||
"The nightly could not be installed: {error}": "Не удалось установить ночную сборку: {error}",
|
||||
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} в сборке Nightly. Выберите Horizon на боковой панели, чтобы играть в релиз.",
|
||||
"No nightly build yet.": "Ночных сборок пока нет.",
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "В этой сборке лаунчера нет входа через Microsoft (HORIZON_MSA_CLIENT_ID), поэтому она не может запустить игру.",
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Чтобы играть, войдите в аккаунт Microsoft, у которого есть Minecraft: Java Edition",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Как и другим лаунчерам, Horizon нужен аккаунт Microsoft, у которого есть Minecraft: Java Edition. С ним вы также играете на онлайн-серверах под своим именем и со своим скином.",
|
||||
@@ -175,6 +169,7 @@
|
||||
"Open {link} in your browser and enter this code:": "Откройте {link} в браузере и введите этот код:",
|
||||
"From my install": "Из моей установки",
|
||||
"Download and prepare": "Скачать и подготовить",
|
||||
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Не подготовлено. «Играть» скачает Minecraft {version} у Mojang для вашего аккаунта и подготовит его.",
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
|
||||
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
|
||||
"Cancel": "Отмена",
|
||||
|
||||
+37
-3
@@ -869,6 +869,18 @@ impl Accounts {
|
||||
/// Plays as `who`: an account, an offline name (kept, only beside an account that owns the
|
||||
/// game), or the game's own name.
|
||||
pub fn play_as(&mut self, store: &dyn SecretStore, who: Player) -> Result<(), String> {
|
||||
self.play_as_with(store, who, false)
|
||||
}
|
||||
|
||||
/// [`Self::play_as`], where `offline_without_owner` (the window's hidden developer option,
|
||||
/// `backend::Settings::dev_offline`) lets an offline name play without an account that owns
|
||||
/// the game.
|
||||
pub fn play_as_with(
|
||||
&mut self,
|
||||
store: &dyn SecretStore,
|
||||
who: Player,
|
||||
offline_without_owner: bool,
|
||||
) -> Result<(), String> {
|
||||
match who {
|
||||
Player::Microsoft(uuid) => {
|
||||
if !self.accounts.iter().any(|a| a.uuid == uuid) {
|
||||
@@ -877,7 +889,7 @@ impl Accounts {
|
||||
self.selected = Some(uuid);
|
||||
}
|
||||
Player::Offline(name) => {
|
||||
if !self.has_owner(store) {
|
||||
if !offline_without_owner && !self.has_owner(store) {
|
||||
return Err(OFFLINE_NEEDS_OWNER.into());
|
||||
}
|
||||
let name = self.add_offline(&name)?;
|
||||
@@ -1054,6 +1066,23 @@ pub fn launch(
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner gate before the game starts. With the window's hidden developer option
|
||||
/// (`backend::Settings::dev_offline`, `offline_without_owner`) no owner is no refusal: the game
|
||||
/// starts offline, without a session and without an `owner`. Downloads never come through here:
|
||||
/// `backend::download_for_owner` asks [`launch`] itself, so the option never downloads the game.
|
||||
pub fn launch_for_game(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
accounts: &mut Accounts,
|
||||
offline_without_owner: bool,
|
||||
) -> Result<Launch, NoOwner> {
|
||||
match launch(ep, client_id, store, accounts) {
|
||||
Err(NoOwner) if offline_without_owner => Ok(Launch::default()),
|
||||
pass => pass,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session for the game, readable by this user only, in a fresh file it deletes
|
||||
/// when it reads it.
|
||||
pub fn write_session_file(
|
||||
@@ -1148,13 +1177,18 @@ impl SignIn {
|
||||
|
||||
/// The owner gate for the game about to start, then the selected account's session in a file
|
||||
/// for it: the file (none: offline) and why the account plays offline this time, if it does.
|
||||
pub fn session_for_game(game_home: &Path) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
|
||||
/// `offline_without_owner`: see [`launch_for_game`].
|
||||
pub fn session_for_game(
|
||||
game_home: &Path,
|
||||
offline_without_owner: bool,
|
||||
) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
|
||||
let mut accounts = Accounts::load();
|
||||
let pass = launch(
|
||||
let pass = launch_for_game(
|
||||
&Endpoints::live(),
|
||||
client_id().as_deref(),
|
||||
store(),
|
||||
&mut accounts,
|
||||
offline_without_owner,
|
||||
)?;
|
||||
let _ = accounts.save();
|
||||
let Some((account, secrets)) = pass.session else {
|
||||
|
||||
@@ -154,7 +154,11 @@ fn serve(mock: Arc<Mutex<Mock>>) -> Endpoints {
|
||||
let body = String::from_utf8(body).unwrap();
|
||||
let (status, text) = {
|
||||
let mut m = mock.lock().unwrap();
|
||||
m.log.push((method.clone(), path.clone(), body.clone()));
|
||||
// A bare `GET /` is a local tool probing every new port (T3 Code does, on the Mac
|
||||
// mini), not the launcher: no endpoint here is the root.
|
||||
if (method.as_str(), path.as_str()) != ("GET", "/") {
|
||||
m.log.push((method.clone(), path.clone(), body.clone()));
|
||||
}
|
||||
route(&mut m, &method, &path, &auth, &body)
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -433,6 +437,51 @@ fn offline_names_play_only_beside_an_owner() {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_developer_option_off_is_the_owner_gate() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = Accounts::default();
|
||||
let refused = accounts.play_as_with(&store, Player::Offline("Builder_1".into()), false);
|
||||
assert_eq!(refused.unwrap_err(), OFFLINE_NEEDS_OWNER);
|
||||
assert_eq!(accounts, Accounts::default());
|
||||
let (_, ep) = mock(|m| m.owns_game = false);
|
||||
let refused = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, false);
|
||||
assert_eq!(refused.unwrap_err(), NoOwner);
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let refused = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, false);
|
||||
assert_eq!(refused.unwrap_err(), NoOwner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_developer_option_plays_offline_names_without_an_owner() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = Accounts::default();
|
||||
accounts
|
||||
.play_as_with(&store, Player::Offline("Builder_1".into()), true)
|
||||
.unwrap();
|
||||
assert_eq!(accounts.player(), Player::Offline("Builder_1".into()));
|
||||
assert_eq!(accounts.name_arg(), Some("Builder_1"));
|
||||
// The game starts offline: no request, no session, and no owner to download the game for.
|
||||
let (m, ep) = mock(|_| {});
|
||||
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
|
||||
assert!(pass.session.is_none() && pass.owner.is_none() && pass.offline.is_none());
|
||||
assert!(m.lock().unwrap().log.is_empty());
|
||||
// Beside an account the services say doesn't own the game: the same, still no owner.
|
||||
let (_, ep) = mock(|m| m.owns_game = false);
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
accounts
|
||||
.play_as_with(&store, Player::Offline("Builder_1".into()), true)
|
||||
.unwrap();
|
||||
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
|
||||
assert!(pass.session.is_none() && pass.owner.is_none());
|
||||
assert!(!accounts.has_owner(&store));
|
||||
// A real owner is still found and brings what it brings without the option.
|
||||
let (_, ep) = mock(|_| {});
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let pass = launch_for_game(&ep, Some("test-client"), &store, &mut accounts, true).unwrap();
|
||||
assert_eq!(pass.owner.unwrap().0.name, "Notch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_have_messages() {
|
||||
let tokens = || MsTokens {
|
||||
|
||||
+302
-32
@@ -37,9 +37,13 @@ pub struct Release {
|
||||
pub body: String,
|
||||
pub date: String,
|
||||
pub asset: Option<(String, u64)>,
|
||||
/// A GitHub pre-release: the Alpha builds from before every release went out as the release
|
||||
/// (v0.2.1 on, docs/release-flow.md). Listed and installed by hand, never as an update.
|
||||
/// Listed and installed by hand, never as an update: a GitHub pre-release of the old Alpha
|
||||
/// channel (v0.2.1 on), or a Nightly older than the current one.
|
||||
pub prerelease: bool,
|
||||
/// A Nightly's GitHub tag (`nightly`, or `nightly-<date>` for the last week's): its files and
|
||||
/// signature are there, while `tag` is the version it installs as (`nightly-2026-10-28-0c02067`).
|
||||
/// None for a release.
|
||||
pub nightly: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
@@ -154,6 +158,9 @@ pub enum ReleaseOrder {
|
||||
/// Numbers without trailing zeros, so 1.2 and 1.2.0 are equal.
|
||||
Before02(Vec<u64>),
|
||||
Semver([u64; 3], Prerelease),
|
||||
/// A Nightly (`nightly-<date>-<commit>`), by its date: newer than every release, since the
|
||||
/// Nightly is the only build Horizon ships.
|
||||
Nightly(String),
|
||||
}
|
||||
|
||||
/// (is a release, the pre-release's identifiers): a release follows every pre-release of its
|
||||
@@ -170,6 +177,9 @@ pub enum Identifier {
|
||||
const FIRST_YEAR: u64 = 1000;
|
||||
|
||||
pub fn release_order(tag: &str) -> ReleaseOrder {
|
||||
if let Some(date) = crate::nightly::date_of(tag) {
|
||||
return ReleaseOrder::Nightly(date.to_owned());
|
||||
}
|
||||
let semver = tag
|
||||
.strip_prefix(LAUNCHER_TAG_PREFIX)
|
||||
.or_else(|| tag.strip_prefix('v'))
|
||||
@@ -270,6 +280,7 @@ pub(crate) fn parse_releases(
|
||||
.collect(),
|
||||
asset: found,
|
||||
prerelease: r["prerelease"].as_bool().unwrap_or(false),
|
||||
nightly: None,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag));
|
||||
@@ -280,10 +291,30 @@ fn is_launcher_tag(tag: &str) -> bool {
|
||||
launcher_version(tag).is_some()
|
||||
}
|
||||
|
||||
/// Published Horizon releases, newest first. Drafts never reach the anonymous API, and launcher
|
||||
/// releases are left out.
|
||||
/// Published Horizon builds, newest first: the current Nightly (the only build Horizon ships since
|
||||
/// 2026-10-28, docs/release-flow.md), the last week's Nightlies, then the releases from before.
|
||||
/// Drafts never reach the anonymous API, and launcher releases are left out.
|
||||
pub fn releases() -> Result<Vec<Release>, String> {
|
||||
Ok(parse_releases(&release_list()?, ASSET, is_game_tag))
|
||||
Ok(game_releases(&release_list()?))
|
||||
}
|
||||
|
||||
/// The game's builds in `list`, newest first. A Nightly is listed under the version it installs as
|
||||
/// ([`crate::nightly::version_of`]); only the current one (`nightly`) is an update.
|
||||
pub(crate) fn game_releases(list: &serde_json::Value) -> Vec<Release> {
|
||||
let mut out = parse_releases(list, ASSET, |tag| {
|
||||
is_game_tag(tag) || horizon_release_sign::is_nightly(tag)
|
||||
});
|
||||
for r in &mut out {
|
||||
if horizon_release_sign::is_nightly(&r.tag) {
|
||||
let version = crate::nightly::version_of(r);
|
||||
let tag = std::mem::replace(&mut r.tag, version);
|
||||
r.prerelease = tag != horizon_release_sign::NIGHTLY;
|
||||
r.nightly = Some(tag);
|
||||
}
|
||||
}
|
||||
// Two Nightlies of one day: the current one first (the dated one is the day's earlier build).
|
||||
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag).then(a.prerelease.cmp(&b.prerelease)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The launcher releases in `list` (the newest 100 releases). Game releases come out several times
|
||||
@@ -556,10 +587,17 @@ fn unpack(archive: &Path, url: &str, to: &Path) -> Result<(), String> {
|
||||
/// the fraction done.
|
||||
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> {
|
||||
let tag = release.tag.as_str();
|
||||
if !is_game_tag(tag) {
|
||||
return Err(format!("{tag} is not a Horizon release."));
|
||||
}
|
||||
let sha256 = signed_digest(tag, ASSET.unwrap_or_default())?;
|
||||
let sha256 = match &release.nightly {
|
||||
// Signed for the Nightly channel, served under its own GitHub tag.
|
||||
Some(published) => crate::signed::nightly_digest(
|
||||
published,
|
||||
ASSET.unwrap_or_default(),
|
||||
&horizon_release_sign::trusted_keys(),
|
||||
fetch_small,
|
||||
)?,
|
||||
None if is_game_tag(tag) => signed_digest(tag, ASSET.unwrap_or_default())?,
|
||||
None => return Err(format!("{tag} is not a Horizon release.")),
|
||||
};
|
||||
install_as(release, tag, &sha256, progress)
|
||||
}
|
||||
|
||||
@@ -1213,8 +1251,9 @@ fn download_for_owner(
|
||||
fetch: sources::Fetch,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<(), String> {
|
||||
let v = &pins.version;
|
||||
let mut accounts = account::Accounts::load();
|
||||
// The strict gate, never `launch_for_game`: the developer option that lets offline names
|
||||
// play without an owner must not download the game for them.
|
||||
let pass = account::launch(
|
||||
&account::Endpoints::live(),
|
||||
account::client_id().as_deref(),
|
||||
@@ -1222,6 +1261,43 @@ fn download_for_owner(
|
||||
&mut accounts,
|
||||
);
|
||||
let _ = accounts.save();
|
||||
download_for(pins, dir, pass, !accounts.accounts.is_empty(), fetch, log)
|
||||
}
|
||||
|
||||
/// Downloads Minecraft `id` (any version on Mojang's list, its version file's sha1 from it) into
|
||||
/// Horizon's own game folder, in the official launcher's layout, for an account that owns the
|
||||
/// game (asked again now). Returns that folder.
|
||||
pub fn download_version_for_owner(
|
||||
id: &str,
|
||||
manifest_sha1: &str,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<PathBuf, String> {
|
||||
let dir = data_dir().join("minecraft");
|
||||
let fetch = |url: &str| -> Result<Vec<u8>, String> {
|
||||
let mut body = Vec::new();
|
||||
get(url)?
|
||||
.into_reader()
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(body)
|
||||
};
|
||||
let pins = sources::Pins::for_version(id, manifest_sha1);
|
||||
download_for_owner(&pins, &dir, &fetch, log)
|
||||
.map_err(|e| e.trim_start_matches("\n • ").to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// What [`download_for_owner`] does with the owner gate's answer: Mojang's files only for its
|
||||
/// `owner`, an account the services said owns the game just now.
|
||||
fn download_for(
|
||||
pins: &sources::Pins,
|
||||
dir: &Path,
|
||||
pass: Result<account::Launch, account::NoOwner>,
|
||||
signed_in: bool,
|
||||
fetch: sources::Fetch,
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<(), String> {
|
||||
let v = &pins.version;
|
||||
match pass {
|
||||
Ok(account::Launch {
|
||||
owner: Some((owner, _)),
|
||||
@@ -1246,7 +1322,7 @@ fn download_for_owner(
|
||||
checked: {}",
|
||||
pass.offline.unwrap_or_default()
|
||||
)),
|
||||
Err(_) if !accounts.accounts.is_empty() => Err(
|
||||
Err(_) if signed_in => Err(
|
||||
"\n • No signed-in account owns Minecraft: Java Edition, so Horizon can't download it."
|
||||
.into(),
|
||||
),
|
||||
@@ -1316,7 +1392,7 @@ fn java(
|
||||
}
|
||||
|
||||
/// The dynamic loader glibc programs on x86-64 Linux start through.
|
||||
const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
|
||||
pub(crate) const GLIBC_LOADER: &str = "/lib64/ld-linux-x86-64.so.2";
|
||||
|
||||
/// Runs `cmd` with Java at `home`: JAVA_HOME names it and its `bin` leads the PATH, as the
|
||||
/// versions' own preparers look for Java.
|
||||
@@ -1913,8 +1989,10 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{tag} is not prepared yet. Prepare it first (it needs your own Minecraft {}).",
|
||||
game_version(tag)
|
||||
"{tag} is not prepared yet. Prepare it first: `horizon-launcher prepare --version {tag}` \
|
||||
uses your own Minecraft {v}, or downloads it from Mojang for the signed-in account \
|
||||
that owns it.",
|
||||
v = game_version(tag)
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1923,7 +2001,7 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
|
||||
/// the gate, a sign-in that can't be renewed (no network, expired) still lets the game start,
|
||||
/// offline.
|
||||
pub fn client(tag: &str) -> Result<Command, String> {
|
||||
let (session, offline) = account::session_for_game(&game_home())
|
||||
let (session, offline) = account::session_for_game(&game_home(), false)
|
||||
.map_err(|refused| format!("{refused} Sign in with `horizon-launcher account sign-in`."))?;
|
||||
need_prepared(tag)?;
|
||||
complete_assets(tag);
|
||||
@@ -2053,12 +2131,15 @@ pub struct Updates {
|
||||
pub launcher: Option<Release>,
|
||||
}
|
||||
|
||||
/// The newest Horizon release with a build here, if it is newer than every installed version.
|
||||
/// The newest Horizon build with a build here (the current Nightly), if it isn't installed and
|
||||
/// no installed version is newer. Two Nightlies of one day compare equal: the one on GitHub is
|
||||
/// the update.
|
||||
pub fn horizon_update(releases: &[Release], installed: &[String]) -> Option<Release> {
|
||||
let newest = latest(releases)?;
|
||||
let newer = installed
|
||||
.iter()
|
||||
.all(|t| compare_versions(&newest.tag, t).is_gt());
|
||||
let newer = !installed.contains(&newest.tag)
|
||||
&& installed
|
||||
.iter()
|
||||
.all(|t| compare_versions(&newest.tag, t).is_ge());
|
||||
newer.then(|| newest.clone())
|
||||
}
|
||||
|
||||
@@ -2098,7 +2179,7 @@ fn releases_and_updates_with(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let releases = parse_releases(&list, ASSET, is_game_tag);
|
||||
let releases = game_releases(&list);
|
||||
let horizon = horizon_update(&installable_updates(&releases), &installed());
|
||||
on_releases(Ok(releases));
|
||||
Ok(Updates {
|
||||
@@ -2112,12 +2193,18 @@ pub fn check_updates() -> Result<Updates, String> {
|
||||
releases_and_updates(|_| {})
|
||||
}
|
||||
|
||||
/// Whether a version installed as an update becomes the selected one: only when the player was
|
||||
/// on the newest installed version before (or had none).
|
||||
/// Whether a version installed as an update becomes the selected one: when the player had none,
|
||||
/// was on a Nightly, or on the newest installed release (so a player of the release channel moves
|
||||
/// to the Nightly at the first check, docs/release-flow.md). An older release picked by hand
|
||||
/// stays; an instance pinned to a version keeps it either way.
|
||||
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
|
||||
match selected {
|
||||
None => true,
|
||||
Some(s) => installed_before.first().is_none_or(|newest| newest == s),
|
||||
Some(s) if crate::nightly::date_of(s).is_some() => true,
|
||||
Some(s) => installed_before
|
||||
.iter()
|
||||
.find(|t| is_game_tag(t))
|
||||
.is_none_or(|newest| newest == s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2146,6 +2233,9 @@ pub fn install_tag(tag: &str, as_update: bool, progress: impl Fn(f32)) -> Result
|
||||
settings.selected = Some(release.tag.clone());
|
||||
settings.save();
|
||||
}
|
||||
if as_update && release.nightly.is_some() {
|
||||
crate::nightly::after_update(&release.tag);
|
||||
}
|
||||
Ok(release.tag.clone())
|
||||
}
|
||||
|
||||
@@ -2473,7 +2563,8 @@ pub enum Msg {
|
||||
Removed(String, Result<(), String>),
|
||||
Line(Job, String),
|
||||
Exit(Job, Result<(), String>),
|
||||
Updates(Result<Updates, String>),
|
||||
/// Boxed: two releases, the largest message by far.
|
||||
Updates(Result<Box<Updates>, String>),
|
||||
LauncherProgress(f32),
|
||||
LauncherUpdated(Result<(), String>),
|
||||
/// `reuse_prepared_everywhere` prepared these versions.
|
||||
@@ -2491,10 +2582,6 @@ pub enum Msg {
|
||||
PackDone(Result<Box<crate::modpacks::Imported>, String>),
|
||||
/// How many play Horizon now, or none when the server can't be reached (`telemetry`).
|
||||
Playing(Option<u64>),
|
||||
/// The nightly being installed, done this far (`nightly`).
|
||||
NightlyProgress(f32),
|
||||
/// A nightly check ended: the version installed (None: already up to date), or why not.
|
||||
Nightly(Result<Option<String>, String>),
|
||||
/// Where the native-mods build is (`native_mods::Step::label`).
|
||||
NativeProgress(String),
|
||||
/// The native-mods build ended: the version built, or why not.
|
||||
@@ -2681,14 +2768,16 @@ pub struct Settings {
|
||||
/// The window's language. Unset follows the system's (`i18n::Language::system`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<crate::i18n::Language>,
|
||||
/// The player turned Nightly on (`nightly`): the nightly build installs and updates in its own
|
||||
/// instance. Off by default.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub nightly: bool,
|
||||
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
|
||||
/// a game of their own. Off by default.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub native_mods: bool,
|
||||
/// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them):
|
||||
/// offline names play without an account that owns the game, in the window only
|
||||
/// (`account::Accounts::play_as_with`, `account::launch_for_game`). Never a download: the game still
|
||||
/// comes from the player's own install. Off by default, and absent from the file while off.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dev_offline: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -2779,6 +2868,107 @@ mod tests {
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The launcher downloads Mojang's game files (client jar, libraries, assets) only for an
|
||||
/// account the services said owns the game: never without one, and never past the gate by
|
||||
/// the developer option (`Settings::dev_offline`), whose pass has no owner.
|
||||
#[test]
|
||||
fn mojang_downloads_only_for_an_owner() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-owner-only-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let pins = sources::Pins::read(&serde_json::json!({ "minecraft": {
|
||||
"version": "26.3",
|
||||
"client_sha1": "00",
|
||||
"manifest_sha1": "00",
|
||||
}}))
|
||||
.unwrap();
|
||||
let asked = std::sync::Mutex::new(Vec::<String>::new());
|
||||
let fetch = |url: &str| -> Result<Vec<u8>, String> {
|
||||
asked.lock().unwrap().push(url.to_string());
|
||||
Err("no network in tests".into())
|
||||
};
|
||||
let log = |_: String| {};
|
||||
let no_owner = || Ok(account::Launch::default());
|
||||
for (pass, signed_in) in [
|
||||
(Err(account::NoOwner), false),
|
||||
(Err(account::NoOwner), true),
|
||||
(no_owner(), false),
|
||||
(no_owner(), true),
|
||||
] {
|
||||
assert!(download_for(&pins, &dir, pass, signed_in, &fetch, &log).is_err());
|
||||
}
|
||||
assert!(asked.lock().unwrap().is_empty());
|
||||
assert!(!dir.exists());
|
||||
// An owner: Mojang is asked (and the download fails here, without network).
|
||||
let owner = account::Account {
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
|
||||
name: "Notch".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
};
|
||||
let secrets = serde_json::from_str(
|
||||
r#"{"refresh_token":"r","minecraft_token":"t","minecraft_expires_at":1,"owns":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pass = Ok(account::Launch {
|
||||
owner: Some((owner, secrets)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(download_for(&pins, &dir, pass, true, &fetch, &log).is_err());
|
||||
assert!(!asked.lock().unwrap().is_empty());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
// In the sources: one call downloads the game, in `download_for`, and the only caller
|
||||
// of that asks the strict gate. Game-file hosts appear nowhere else.
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = vec![src.clone()];
|
||||
let mut sources = Vec::new();
|
||||
while let Some(path) = files.pop() {
|
||||
if path.is_dir() {
|
||||
files.extend(fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let in_tests = name.contains("tests") || path.parent().unwrap() != src;
|
||||
// The code alone: test modules (this one included) come after it.
|
||||
// A Windows checkout has CRLF line ends: the searches below look for "\n".
|
||||
let mut text = fs::read_to_string(&path).unwrap().replace("\r\n", "\n");
|
||||
text.truncate(text.find("#[cfg(test)]").unwrap_or(text.len()));
|
||||
sources.push((name, in_tests, text));
|
||||
}
|
||||
}
|
||||
let calls: Vec<_> = sources
|
||||
.iter()
|
||||
.filter(|(_, tests, _)| !tests)
|
||||
.flat_map(|(name, _, text)| {
|
||||
text.matches("mojang::download(")
|
||||
.map(move |_| name.as_str())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(calls, ["backend.rs"]);
|
||||
let backend = &sources.iter().find(|(n, ..)| n == "backend.rs").unwrap().2;
|
||||
let body = |f: &str| {
|
||||
let start = backend.find(&format!("\nfn {f}(")).unwrap();
|
||||
&backend[start..start + backend[start + 1..].find("\n}\n").unwrap()]
|
||||
};
|
||||
assert!(body("download_for").contains("mojang::download("));
|
||||
assert!(body("download_for_owner").contains("account::launch("));
|
||||
assert!(!body("download_for_owner").contains("launch_for_game("));
|
||||
// `download_for` is defined once and called once, by `download_for_owner`.
|
||||
assert_eq!(backend.matches("download_for(").count(), 2);
|
||||
assert!(body("download_for_owner").contains("download_for("));
|
||||
for host in [
|
||||
"piston-data",
|
||||
"libraries.minecraft.net",
|
||||
"resources.download.minecraft.net",
|
||||
] {
|
||||
for (name, tests, text) in &sources {
|
||||
assert!(
|
||||
*tests || name == "mojang.rs" || !text.contains(host),
|
||||
"{name} names {host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A preparation that reuses the imported game files still reads the assets from the
|
||||
/// launcher that has them: a Prism Launcher instance's are in the launcher root, linked into
|
||||
/// the official layout (jan.sonki on Discord: silent, grey title screen since 0.2.48).
|
||||
@@ -3148,6 +3338,7 @@ mod tests {
|
||||
date: String::new(),
|
||||
asset: Some(("u".into(), 1)),
|
||||
prerelease: false,
|
||||
nightly: None,
|
||||
};
|
||||
assert!(launcher_update(&[r(own_tag())]).is_none());
|
||||
assert_eq!(launcher_version(&own_tag()), Some(VERSION));
|
||||
@@ -3188,6 +3379,7 @@ mod tests {
|
||||
date: String::new(),
|
||||
asset: Some(("u".into(), 1)),
|
||||
prerelease: false,
|
||||
nightly: None,
|
||||
};
|
||||
let mut list = vec![
|
||||
r("launcher-v0.16.0"),
|
||||
@@ -3354,6 +3546,7 @@ mod tests {
|
||||
date: String::new(),
|
||||
asset: Some(("u".into(), 1)),
|
||||
prerelease: false,
|
||||
nightly: None,
|
||||
};
|
||||
let releases = [r("v2026.10.13"), r("v2026.10.12")];
|
||||
assert_eq!(
|
||||
@@ -3370,6 +3563,83 @@ mod tests {
|
||||
assert!(select_after_update(None, &installed));
|
||||
}
|
||||
|
||||
/// The Nightly is the only build Horizon ships (docs/release-flow.md): every launcher takes the
|
||||
/// current one as its update, over every release; the last week's are listed, never offered.
|
||||
#[test]
|
||||
fn every_launcher_updates_to_the_current_nightly() {
|
||||
let asset = ASSET.unwrap_or("horizon-linux-x86_64.tar.gz");
|
||||
let build = |url: &str| serde_json::json!([{"name": asset, "browser_download_url": url, "size": 1}]);
|
||||
let list = serde_json::json!([
|
||||
{"tag_name": "nightly", "name": "Nightly 2026-10-28 · 9b772b2", "prerelease": true,
|
||||
"published_at": "2026-10-28T09:58:01Z", "assets": build("n")},
|
||||
{"tag_name": "[email protected]", "assets": []},
|
||||
{"tag_name": "v0.2.95", "assets": build("r")},
|
||||
{"tag_name": "nightly-2026-10-28", "name": "Nightly 2026-10-28 · 83f15fa", "prerelease": true,
|
||||
"published_at": "2026-10-28T07:54:57Z", "assets": build("d")},
|
||||
{"tag_name": "nightly-2026-10-27", "name": "Nightly 2026-10-27 · 7954902", "prerelease": true,
|
||||
"published_at": "2026-10-28T00:15:40Z", "assets": build("e")},
|
||||
]);
|
||||
let releases = game_releases(&list);
|
||||
let listed: Vec<_> = releases
|
||||
.iter()
|
||||
.map(|r| (r.tag.as_str(), r.nightly.as_deref(), r.prerelease))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
[
|
||||
("nightly-2026-10-28-9b772b2", Some("nightly"), false),
|
||||
(
|
||||
"nightly-2026-10-28-83f15fa",
|
||||
Some("nightly-2026-10-28"),
|
||||
true
|
||||
),
|
||||
(
|
||||
"nightly-2026-10-27-7954902",
|
||||
Some("nightly-2026-10-27"),
|
||||
true
|
||||
),
|
||||
("v0.2.95", None, false),
|
||||
]
|
||||
);
|
||||
let offered = |installed: &[&str]| {
|
||||
let installed: Vec<String> = installed.iter().map(|t| t.to_string()).collect();
|
||||
horizon_update(&installable_updates(&releases), &installed).map(|r| r.tag)
|
||||
};
|
||||
let current = Some("nightly-2026-10-28-9b772b2".to_string());
|
||||
// A player of the release channel, a new player, one on an older Nightly of the same day.
|
||||
assert_eq!(offered(&["v0.2.95", "v0.2.94"]), current);
|
||||
assert_eq!(offered(&[]), current);
|
||||
assert_eq!(offered(&["nightly-2026-10-28-83f15fa", "v0.2.95"]), current);
|
||||
assert_eq!(offered(&["nightly-2026-10-28-9b772b2"]), None);
|
||||
// Nightlies come after every release in every list.
|
||||
assert!(compare_versions("nightly-2026-10-27-7954902", "v0.2.95").is_gt());
|
||||
assert!(
|
||||
compare_versions("nightly-2026-10-28-9b772b2", "nightly-2026-10-27-7954902").is_gt()
|
||||
);
|
||||
}
|
||||
|
||||
/// Players move to the Nightly at their next check: whoever was on the newest release, on a
|
||||
/// Nightly, or on nothing. A release picked by hand stays picked.
|
||||
#[test]
|
||||
fn players_of_the_newest_release_move_to_the_nightly() {
|
||||
let installed = [
|
||||
"nightly-2026-10-27-7954902".to_string(),
|
||||
"v0.2.95".into(),
|
||||
"v0.2.90".into(),
|
||||
];
|
||||
assert!(select_after_update(Some("v0.2.95"), &installed));
|
||||
assert!(select_after_update(
|
||||
Some("nightly-2026-10-27-7954902"),
|
||||
&installed
|
||||
));
|
||||
assert!(select_after_update(None, &installed));
|
||||
assert!(!select_after_update(Some("v0.2.90"), &installed));
|
||||
assert!(select_after_update(
|
||||
Some("v0.2.95"),
|
||||
&["v0.2.95".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_versions_install_automatically_unless_turned_off() {
|
||||
// No settings file, an old one that never mentioned it, and 0.1.1's default `false`.
|
||||
|
||||
@@ -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))?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Developer options: a Settings section nothing in the window points to. Seven clicks on the
|
||||
//! sidebar's "Launcher x.y.z" reveal it for this run; it stays shown while one of its options is
|
||||
//! on. Its one option lets offline names play without an account that owns the game
|
||||
//! (`backend::Settings::dev_offline`). The game still comes from the player's own install: the
|
||||
//! launcher downloads Mojang's files only for an owner (`backend::download_for_owner`).
|
||||
use super::*;
|
||||
|
||||
/// Clicks on the launcher's version that reveal the section.
|
||||
const CLICKS: u8 = 7;
|
||||
|
||||
impl Launcher {
|
||||
/// A click on the sidebar's launcher version; the last one opens Settings on the section.
|
||||
pub(super) fn version_clicked(&mut self) {
|
||||
if self.dev_options {
|
||||
return;
|
||||
}
|
||||
self.dev_clicks += 1;
|
||||
if self.dev_clicks >= CLICKS {
|
||||
self.dev_options = true;
|
||||
self.go(Page::Settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// The section, when revealed or when one of its options is on.
|
||||
pub(super) fn dev_options_section(&self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
if !self.dev_options && !self.settings.dev_offline {
|
||||
return None;
|
||||
}
|
||||
let t = self.theme;
|
||||
let toggle = switch("dev-offline", self.settings.dev_offline, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.dev_offline = !this.settings.dev_offline;
|
||||
this.settings.save();
|
||||
cx.notify();
|
||||
},
|
||||
));
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
section_title("Developer options", "terminal", &t)
|
||||
.mt(px(20.))
|
||||
.mb(px(12.)),
|
||||
)
|
||||
.child(index_row(
|
||||
0,
|
||||
"Offline names without an account",
|
||||
Some(
|
||||
div()
|
||||
.child(
|
||||
"Offline names play without a Microsoft account that owns the game. \
|
||||
Minecraft still comes from your own install.",
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(toggle.into_any_element()),
|
||||
&t,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,322 @@
|
||||
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
|
||||
//! official launcher reads it: the main class, the classpath of this system's libraries and the
|
||||
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
|
||||
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
|
||||
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
|
||||
//!
|
||||
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
|
||||
//! one game folder. Nothing here reaches the network.
|
||||
|
||||
use crate::mojang;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
|
||||
pub struct Player {
|
||||
pub name: String,
|
||||
pub uuid: String,
|
||||
pub token: String,
|
||||
pub xuid: String,
|
||||
}
|
||||
|
||||
/// Everything the command needs besides the version JSON.
|
||||
pub struct Launch<'a> {
|
||||
/// Holds `versions/`, `libraries/`, `assets/`.
|
||||
pub minecraft: &'a Path,
|
||||
/// The instance's game folder (saves, options.txt, mods).
|
||||
pub game_dir: &'a Path,
|
||||
/// Where an old version's natives are unpacked.
|
||||
pub natives: &'a Path,
|
||||
/// The `java` program.
|
||||
pub java: &'a Path,
|
||||
pub memory_mb: u32,
|
||||
/// The player's own JVM arguments.
|
||||
pub jvm_args: &'a [String],
|
||||
pub player: &'a Player,
|
||||
/// `osx`, `linux` or `windows`, as version files name systems.
|
||||
pub os: &'a str,
|
||||
}
|
||||
|
||||
/// This system's name in version files.
|
||||
pub fn os_name() -> &'static str {
|
||||
match crate::sources::Os::current() {
|
||||
crate::sources::Os::Linux => "linux",
|
||||
crate::sources::Os::MacOs => "osx",
|
||||
crate::sources::Os::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
|
||||
/// Every optional feature (demo, custom resolution, quick play) is off.
|
||||
fn allowed(rules: &Value, os: &str) -> bool {
|
||||
let Some(rules) = rules.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let arch_matches = |arch: &str| match arch {
|
||||
"x86" => cfg!(target_arch = "x86"),
|
||||
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
|
||||
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
|
||||
_ => false,
|
||||
};
|
||||
let mut allow = false;
|
||||
for rule in rules {
|
||||
let system = &rule["os"];
|
||||
let matches = system["name"].as_str().is_none_or(|n| n == os)
|
||||
&& system["arch"].as_str().is_none_or(arch_matches)
|
||||
&& rule["features"]
|
||||
.as_object()
|
||||
.is_none_or(|f| f.values().all(|v| v == false));
|
||||
if matches {
|
||||
allow = rule["action"] == "allow";
|
||||
}
|
||||
}
|
||||
allow
|
||||
}
|
||||
|
||||
/// The libraries this system uses, then the client jar.
|
||||
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
|
||||
let id = version["id"].as_str().ok_or("The version file has no id")?;
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
|
||||
let path = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.push(
|
||||
minecraft
|
||||
.join("versions")
|
||||
.join(mojang::relative(id)?)
|
||||
.join(format!("{id}.jar")),
|
||||
);
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
|
||||
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
|
||||
pub fn unpack_natives(
|
||||
version: &Value,
|
||||
minecraft: &Path,
|
||||
os: &str,
|
||||
to: &Path,
|
||||
) -> Result<(), String> {
|
||||
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
for library in version["libraries"].as_array().into_iter().flatten() {
|
||||
if !allowed(&library["rules"], os) {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = mojang::native_classifier(library, os) else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
|
||||
let exclude: Vec<&str> = library["extract"]["exclude"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(name) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let text = name.to_string_lossy().replace('\\', "/");
|
||||
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
|
||||
continue;
|
||||
}
|
||||
let out = to.join(&name);
|
||||
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
|
||||
/// the objects copied there once; the asset store itself for every other version.
|
||||
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
|
||||
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
|
||||
let assets = minecraft.join("assets");
|
||||
let index = assets
|
||||
.join("indexes")
|
||||
.join(mojang::relative(&format!("{id}.json"))?);
|
||||
let Ok(text) = fs::read_to_string(&index) else {
|
||||
return Ok(assets);
|
||||
};
|
||||
let index: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
|
||||
let to = if index["map_to_resources"] == true {
|
||||
game_dir.join("resources")
|
||||
} else if index["virtual"] == true {
|
||||
assets.join("virtual").join(mojang::relative(id)?)
|
||||
} else {
|
||||
return Ok(assets);
|
||||
};
|
||||
for (name, object) in index["objects"].as_object().into_iter().flatten() {
|
||||
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
|
||||
continue;
|
||||
};
|
||||
let out = to.join(mojang::relative(name)?);
|
||||
if !out.is_file() {
|
||||
let from = assets.join("objects").join(&hash[..2]).join(hash);
|
||||
fs::create_dir_all(out.parent().unwrap())
|
||||
.and_then(|_| fs::copy(&from, &out))
|
||||
.map_err(|e| format!("{}: {e}", from.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
|
||||
fn list(args: &Value, os: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for arg in args.as_array().into_iter().flatten() {
|
||||
match arg {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
|
||||
Value::String(s) => out.push(s.clone()),
|
||||
Value::Array(values) => {
|
||||
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `${name}` replaced by its value; an unknown one stays as written.
|
||||
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = arg;
|
||||
while let Some(start) = rest.find("${") {
|
||||
let Some(len) = rest[start..].find('}') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..start]);
|
||||
let key = &rest[start + 2..start + len];
|
||||
match vars.get(key) {
|
||||
Some(value) => out.push_str(value),
|
||||
None => out.push_str(&rest[start..start + len + 1]),
|
||||
}
|
||||
rest = &rest[start + len + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
|
||||
/// Unpacks natives and lays out old assets first.
|
||||
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
|
||||
let id = version["id"].as_str().ok_or("The version file has no id")?;
|
||||
let main = version["mainClass"]
|
||||
.as_str()
|
||||
.ok_or("The version file has no mainClass")?;
|
||||
let separator = if cfg!(windows) { ";" } else { ":" };
|
||||
let classpath = classpath(version, launch.minecraft, launch.os)?;
|
||||
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
|
||||
let assets = launch.minecraft.join("assets");
|
||||
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
|
||||
let path = |p: &Path| p.to_string_lossy().into_owned();
|
||||
let p = launch.player;
|
||||
let vars: HashMap<&str, String> = HashMap::from([
|
||||
("auth_player_name", p.name.clone()),
|
||||
("auth_uuid", p.uuid.clone()),
|
||||
("auth_access_token", p.token.clone()),
|
||||
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
|
||||
("auth_xuid", p.xuid.clone()),
|
||||
("clientid", String::new()),
|
||||
("user_type", "msa".into()),
|
||||
("user_properties", "{}".into()),
|
||||
("version_name", id.to_string()),
|
||||
(
|
||||
"version_type",
|
||||
version["type"].as_str().unwrap_or("release").into(),
|
||||
),
|
||||
("game_directory", path(launch.game_dir)),
|
||||
("assets_root", path(&assets)),
|
||||
("game_assets", path(&game_assets)),
|
||||
(
|
||||
"assets_index_name",
|
||||
version["assetIndex"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or("legacy")
|
||||
.into(),
|
||||
),
|
||||
("natives_directory", path(launch.natives)),
|
||||
(
|
||||
"library_directory",
|
||||
path(&launch.minecraft.join("libraries")),
|
||||
),
|
||||
("classpath_separator", separator.into()),
|
||||
(
|
||||
"classpath",
|
||||
classpath
|
||||
.iter()
|
||||
.map(|c| path(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator),
|
||||
),
|
||||
("launcher_name", "horizon-launcher".into()),
|
||||
("launcher_version", env!("CARGO_PKG_VERSION").into()),
|
||||
]);
|
||||
let mut jvm = vec![
|
||||
format!("-Xmx{}M", launch.memory_mb),
|
||||
// Whatever the logging setup, never look names up in log messages.
|
||||
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
|
||||
];
|
||||
let logging = &version["logging"]["client"];
|
||||
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
|
||||
{
|
||||
let config = launch
|
||||
.minecraft
|
||||
.join("assets")
|
||||
.join("log_configs")
|
||||
.join(mojang::relative(file)?);
|
||||
jvm.push(arg.replace("${path}", &path(&config)));
|
||||
}
|
||||
let (jvm_list, game) = match version["arguments"].as_object() {
|
||||
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
|
||||
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
|
||||
None => (
|
||||
vec![
|
||||
"-Djava.library.path=${natives_directory}".into(),
|
||||
"-cp".into(),
|
||||
"${classpath}".into(),
|
||||
],
|
||||
version["minecraftArguments"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
|
||||
jvm.extend(launch.jvm_args.iter().cloned());
|
||||
let mut cmd = Command::new(launch.java);
|
||||
cmd.current_dir(launch.game_dir)
|
||||
.args(&jvm)
|
||||
.arg(main)
|
||||
.args(game.iter().map(|a| fill(a, &vars)));
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "java_launch/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
|
||||
//! a scratch folder.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// `rel` (`a/b/c`) under `base`, one part at a time as the code joins them (Windows paths).
|
||||
fn under(base: &Path, rel: &str) -> PathBuf {
|
||||
rel.split('/')
|
||||
.fold(base.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn player() -> Player {
|
||||
Player {
|
||||
name: "Notch".into(),
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
|
||||
token: "mc-token".into(),
|
||||
xuid: "2535400000000000".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn args(cmd: &Command) -> Vec<String> {
|
||||
cmd.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn modern() -> Value {
|
||||
json!({
|
||||
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "19"},
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
|
||||
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
|
||||
"--userType", "${user_type}",
|
||||
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
|
||||
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
|
||||
"value": ["--width", "${resolution_width}"]}],
|
||||
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
|
||||
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
|
||||
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
|
||||
"-cp", "${classpath}"]
|
||||
},
|
||||
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
|
||||
"file": {"id": "client-1.21.2.xml"}}},
|
||||
"libraries": [
|
||||
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
|
||||
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
|
||||
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
|
||||
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
|
||||
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
|
||||
let dir = scratch("modern");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let java = dir.join("java");
|
||||
let p = player();
|
||||
let extra = vec!["-XX:+UseZGC".to_string()];
|
||||
let launch = |os| Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 6144,
|
||||
jvm_args: &extra,
|
||||
player: &p,
|
||||
os,
|
||||
};
|
||||
let cmd = command(&modern(), &launch("osx")).unwrap();
|
||||
let a = args(&cmd);
|
||||
let at = |s: &str| {
|
||||
a.iter()
|
||||
.position(|x| x == s)
|
||||
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
|
||||
};
|
||||
assert_eq!(a[0], "-Xmx6144M");
|
||||
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
|
||||
let config = under(&mc, "assets/log_configs/client-1.21.2.xml");
|
||||
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
|
||||
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
|
||||
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
|
||||
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
|
||||
assert_eq!(a[at("--username") + 1], "Notch");
|
||||
assert_eq!(a[at("--accessToken") + 1], "mc-token");
|
||||
assert_eq!(a[at("--assetIndex") + 1], "19");
|
||||
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
|
||||
assert_eq!(a[at("--userType") + 1], "msa");
|
||||
// Features are off: no demo, no custom size.
|
||||
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
|
||||
let lib = |p: &str| {
|
||||
under(&mc.join("libraries"), p)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
assert_eq!(
|
||||
cp,
|
||||
[
|
||||
lib("a/common/1/common-1.jar"),
|
||||
lib("a/mac/1/mac-1.jar"),
|
||||
under(&mc, "versions/1.21.4/1.21.4.jar")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
]
|
||||
);
|
||||
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
|
||||
|
||||
let linux = args(&command(&modern(), &launch("linux")).unwrap());
|
||||
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
|
||||
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
|
||||
// The x86-only rule holds on 32-bit Windows alone.
|
||||
let windows = args(&command(&modern(), &launch("windows")).unwrap());
|
||||
assert_eq!(
|
||||
windows.contains(&"-Xss1M".into()),
|
||||
cfg!(target_arch = "x86")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
|
||||
/// virtual asset index laid out by name.
|
||||
#[test]
|
||||
fn an_old_version_gets_its_natives_and_assets_by_name() {
|
||||
let dir = scratch("old");
|
||||
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
|
||||
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
|
||||
fs::create_dir_all(jar.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for (name, body) in [
|
||||
("liblwjgl.dylib", b"lib".as_slice()),
|
||||
("META-INF/MANIFEST.MF", b"m"),
|
||||
] {
|
||||
zip.start_file(name, opts).unwrap();
|
||||
zip.write_all(body).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let hash = "0123456789abcdef0123456789abcdef01234567";
|
||||
let object = mc.join("assets/objects/01").join(hash);
|
||||
fs::create_dir_all(object.parent().unwrap()).unwrap();
|
||||
fs::write(&object, b"ogg").unwrap();
|
||||
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
|
||||
fs::write(
|
||||
mc.join("assets/indexes/1.8.json"),
|
||||
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let version = json!({
|
||||
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||
"assetIndex": {"id": "1.8"},
|
||||
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
|
||||
"libraries": [{
|
||||
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
|
||||
"natives": {"osx": "natives-osx"},
|
||||
"extract": {"exclude": ["META-INF/"]},
|
||||
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
|
||||
}]
|
||||
});
|
||||
let (java, p) = (dir.join("java"), player());
|
||||
let launch = Launch {
|
||||
minecraft: &mc,
|
||||
game_dir: &game,
|
||||
natives: &natives,
|
||||
java: &java,
|
||||
memory_mb: 2048,
|
||||
jvm_args: &[],
|
||||
player: &p,
|
||||
os: "osx",
|
||||
};
|
||||
let a = args(&command(&version, &launch).unwrap());
|
||||
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
|
||||
assert!(!natives.join("META-INF").exists());
|
||||
let virtual_dir = under(&mc, "assets/virtual/1.8");
|
||||
assert_eq!(
|
||||
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
|
||||
b"ogg"
|
||||
);
|
||||
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
|
||||
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
a[at("--session") + 1],
|
||||
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
|
||||
);
|
||||
assert_eq!(a[at("--userProperties") + 1], "{}");
|
||||
assert_eq!(a[at("--unknown") + 1], "${nothing}");
|
||||
assert_eq!(a[0], "-Xmx2048M");
|
||||
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
|
||||
// Natives are not on the classpath; the client jar is.
|
||||
let cp = &a[at("-cp") + 1];
|
||||
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_fill_in_place() {
|
||||
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
|
||||
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
|
||||
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
|
||||
}
|
||||
@@ -14,7 +14,10 @@ pub mod heads;
|
||||
pub mod i18n;
|
||||
pub mod import;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
pub mod launch;
|
||||
pub mod minecraft_versions;
|
||||
pub mod modpacks;
|
||||
pub mod mojang;
|
||||
pub mod native_mods;
|
||||
|
||||
+126
-142
@@ -13,6 +13,7 @@ mod console;
|
||||
mod console_page;
|
||||
mod cull;
|
||||
mod datapacks_page;
|
||||
mod dev_options;
|
||||
mod export_page;
|
||||
mod game_tools;
|
||||
mod import_page;
|
||||
@@ -54,7 +55,6 @@ use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Upda
|
||||
use horizon_launcher::i18n::{self, tr, trf};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::native_mods;
|
||||
use horizon_launcher::nightly;
|
||||
use horizon_launcher::telemetry;
|
||||
use scrollbar::Scrollbar;
|
||||
use std::borrow::Cow;
|
||||
@@ -171,12 +171,6 @@ struct Launcher {
|
||||
/// nothing can launch, select or prepare it, and its row can't install or remove it again.
|
||||
removing: Option<String>,
|
||||
install_error: Option<(String, String)>,
|
||||
/// Nightly (`horizon_launcher::nightly`): how far the nightly being installed is, and why the
|
||||
/// last check or install failed.
|
||||
nightly_progress: Option<f32>,
|
||||
nightly_error: Option<String>,
|
||||
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
|
||||
nightly_select: bool,
|
||||
/// Native mods (`native_mods`): the build's step while it runs, why it failed, and the
|
||||
/// warning before a build while it is open, with the mods it names.
|
||||
native_step: Option<String>,
|
||||
@@ -219,8 +213,14 @@ struct Launcher {
|
||||
owner: Option<bool>,
|
||||
/// Play was refused (or Home's Sign in pressed): the owner gate's screen is open.
|
||||
owner_screen_open: bool,
|
||||
/// Clicks on the sidebar's launcher version, and whether they revealed the developer options
|
||||
/// (`dev_options`) for this run.
|
||||
dev_clicks: u8,
|
||||
dev_options: bool,
|
||||
/// The game is waiting for its session (a token renewal) before it starts.
|
||||
starting_client: Option<String>,
|
||||
/// Play was pressed on a version not prepared yet: it starts once this preparation ends well.
|
||||
play_when_prepared: Option<String>,
|
||||
/// Join a friend: the code typed on the Play page, and the one the game
|
||||
/// starting now joins.
|
||||
friend_code: String,
|
||||
@@ -392,9 +392,6 @@ impl Launcher {
|
||||
installing: None,
|
||||
removing: None,
|
||||
install_error: None,
|
||||
nightly_progress: None,
|
||||
nightly_error: None,
|
||||
nightly_select: false,
|
||||
native_step: None,
|
||||
native_error: None,
|
||||
native_confirm: None,
|
||||
@@ -428,7 +425,10 @@ impl Launcher {
|
||||
keychain: true,
|
||||
owner: None,
|
||||
owner_screen_open: false,
|
||||
dev_clicks: 0,
|
||||
dev_options: false,
|
||||
starting_client: None,
|
||||
play_when_prepared: None,
|
||||
friend_code: String::new(),
|
||||
friend_focused: false,
|
||||
friend_focus: cx.focus_handle(),
|
||||
@@ -522,7 +522,7 @@ impl Launcher {
|
||||
self.checked = Some(Instant::now());
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(Msg::Updates(backend::check_updates()));
|
||||
let _ = tx.send(Msg::Updates(backend::check_updates().map(Box::new)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -565,30 +565,6 @@ impl Launcher {
|
||||
self.install(&release);
|
||||
}
|
||||
|
||||
/// With Nightly on, installs the current nightly in the Nightly instance if it is new.
|
||||
fn update_nightly(&mut self) {
|
||||
if !self.settings.nightly || self.nightly_progress.is_some() {
|
||||
return;
|
||||
}
|
||||
self.nightly_progress = Some(0.);
|
||||
self.nightly_error = None;
|
||||
let (tx, installed) = (self.tx.clone(), self.installed.clone());
|
||||
std::thread::spawn(move || {
|
||||
let result = nightly::latest().and_then(|latest| {
|
||||
let Some(release) = nightly::update(latest, &installed) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let progress = |p| {
|
||||
let _ = tx.send(Msg::NightlyProgress(p));
|
||||
};
|
||||
nightly::install(&release, progress).map(|_| Some(nightly::version_of(&release)))
|
||||
});
|
||||
let _ = tx.send(Msg::Nightly(result));
|
||||
});
|
||||
}
|
||||
|
||||
/// Turns Nightly on (install it, then play its instance) or off (play the release again; the
|
||||
/// Nightly instance and its worlds stay).
|
||||
/// The native mods turned on in the instance Play starts, unpacked and checked, with their
|
||||
/// bundles: what the warning names and the build compiles.
|
||||
fn native_mods_to_build(
|
||||
@@ -639,17 +615,6 @@ impl Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_nightly(&mut self, on: bool) {
|
||||
self.settings.nightly = on;
|
||||
if on {
|
||||
self.nightly_select = true;
|
||||
} else if self.settings.instance.as_deref() == Some(nightly::INSTANCE) {
|
||||
self.settings.instance = None;
|
||||
}
|
||||
self.settings.save();
|
||||
self.update_nightly();
|
||||
}
|
||||
|
||||
fn fetch_releases(&mut self) {
|
||||
self.releases = Load::Loading;
|
||||
let tx = self.tx.clone();
|
||||
@@ -665,7 +630,7 @@ impl Launcher {
|
||||
let updates = backend::releases_and_updates(|releases| {
|
||||
let _ = tx.send(Msg::Releases(releases));
|
||||
});
|
||||
let _ = tx.send(Msg::Updates(updates));
|
||||
let _ = tx.send(Msg::Updates(updates.map(Box::new)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -721,7 +686,10 @@ impl Launcher {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.owner = Some(true);
|
||||
// Past the gate by the developer option alone is no owner.
|
||||
if !self.settings.dev_offline {
|
||||
self.owner = Some(true);
|
||||
}
|
||||
if let Some(file) = file {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
}
|
||||
@@ -826,17 +794,15 @@ impl Launcher {
|
||||
}
|
||||
Msg::Updates(Ok(updates)) => {
|
||||
self.update_error = None;
|
||||
self.updates = updates;
|
||||
self.updates = *updates;
|
||||
if self.settings.auto_install() {
|
||||
self.install_update();
|
||||
}
|
||||
if let Some(release) = self.updates.launcher.clone() {
|
||||
self.update_launcher(release);
|
||||
}
|
||||
self.update_nightly();
|
||||
}
|
||||
Msg::Updates(Err(e)) => self.update_error = Some(e),
|
||||
Msg::NightlyProgress(p) => self.nightly_progress = Some(p),
|
||||
Msg::NativeProgress(step) => self.native_step = Some(step),
|
||||
Msg::Native(result) => {
|
||||
self.native_step = None;
|
||||
@@ -846,28 +812,6 @@ impl Launcher {
|
||||
self.native_error = Some(e);
|
||||
}
|
||||
}
|
||||
Msg::Nightly(result) => {
|
||||
self.nightly_progress = None;
|
||||
self.installed = backend::installed();
|
||||
self.reload_instances();
|
||||
match result {
|
||||
Ok(_)
|
||||
if self.nightly_select
|
||||
&& self.settings.nightly
|
||||
&& self
|
||||
.instances
|
||||
.list
|
||||
.iter()
|
||||
.any(|i| i.id == nightly::INSTANCE) =>
|
||||
{
|
||||
self.nightly_select = false;
|
||||
self.settings.instance = Some(nightly::INSTANCE.into());
|
||||
self.settings.save();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => self.nightly_error = Some(e),
|
||||
}
|
||||
}
|
||||
Msg::LauncherProgress(p) => {
|
||||
if let SelfUpdate::Downloading(_, f) = &mut self.self_update {
|
||||
*f = p;
|
||||
@@ -911,6 +855,13 @@ impl Launcher {
|
||||
match job {
|
||||
Job::Prepare => {
|
||||
self.preparing = false;
|
||||
// Play pressed before the version was prepared plays now.
|
||||
if let Some(tag) = self.play_when_prepared.take() {
|
||||
if error.is_none() && backend::is_prepared(&tag) {
|
||||
self.starting_pack = None;
|
||||
self.start_client(tag);
|
||||
}
|
||||
}
|
||||
if let (None, Some(started)) = (&error, self.prepare_started.take()) {
|
||||
self.perf.record_since("launcher.prepare_ms", started);
|
||||
}
|
||||
@@ -1164,9 +1115,10 @@ impl Launcher {
|
||||
self.starting_client = Some(tag.clone());
|
||||
self.play_pressed = Some(Instant::now());
|
||||
let tx = self.tx.clone();
|
||||
let offline_without_owner = self.settings.dev_offline;
|
||||
std::thread::spawn(move || {
|
||||
backend::complete_assets(&tag);
|
||||
let session = account::session_for_game(&backend::game_home());
|
||||
let session = account::session_for_game(&backend::game_home(), offline_without_owner);
|
||||
let _ = tx.send(Msg::Session(session));
|
||||
});
|
||||
}
|
||||
@@ -1533,6 +1485,27 @@ fn segments<T: Copy + PartialEq + 'static>(
|
||||
}))
|
||||
}
|
||||
|
||||
/// What the Play button does for the selected version.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PlayPress {
|
||||
Play,
|
||||
/// Not prepared, for an account that owns the game: prepare it (from the player's install,
|
||||
/// else downloaded from Mojang for that account), then play, as the official launcher does.
|
||||
PrepareThenPlay,
|
||||
/// Not prepared and no owner (signing in comes first), or busy preparing or playing.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// `owner`: whether an account here owns the game, by the keychain (`Launcher::owner`).
|
||||
fn play_press(prepared: bool, owner: Option<bool>, busy: bool) -> PlayPress {
|
||||
match (prepared, owner) {
|
||||
_ if busy => PlayPress::Nothing,
|
||||
(true, _) => PlayPress::Play,
|
||||
(false, Some(true)) => PlayPress::PrepareThenPlay,
|
||||
(false, _) => PlayPress::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected version once one has been removed: the saved choice if it is still installed,
|
||||
/// else the newest left.
|
||||
fn selection_after_removal(installed: &[String], saved: Option<String>) -> Option<String> {
|
||||
@@ -1567,6 +1540,15 @@ fn page_header(title: &str, description: &str, t: &Theme) -> Div {
|
||||
/// One row of a list, as its own panel: a name in the pixel face with an optional detail under
|
||||
/// it, then a value or a control on the right. Rows were numbered in the quiet look; `_n` keeps
|
||||
/// the call sites as they are.
|
||||
/// How an available update is named: a Nightly by its title ("Nightly 2026-10-28 · 0c02067"), a
|
||||
/// release by its tag.
|
||||
fn update_name(release: &Release) -> &str {
|
||||
match release.nightly {
|
||||
Some(_) => &release.name,
|
||||
None => &release.tag,
|
||||
}
|
||||
}
|
||||
|
||||
fn index_row(
|
||||
_n: usize,
|
||||
name: impl IntoElement,
|
||||
@@ -2098,9 +2080,14 @@ impl Launcher {
|
||||
}
|
||||
foot = foot.child(
|
||||
caps(format!("Launcher {}", backend::VERSION))
|
||||
.id("launcher-version")
|
||||
.px(px(8.))
|
||||
.pt(px(6.))
|
||||
.text_color(t.muted),
|
||||
.text_color(t.muted)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.version_clicked();
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
div()
|
||||
.id("sidebar")
|
||||
@@ -2287,14 +2274,23 @@ impl Launcher {
|
||||
.top(px(17.))
|
||||
.child(blocks::icon("play", 22., t.on_face)),
|
||||
);
|
||||
if !prepared || self.preparing || running {
|
||||
play = disabled(play);
|
||||
} else {
|
||||
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.picker_open = false;
|
||||
this.play();
|
||||
cx.notify();
|
||||
}));
|
||||
match play_press(prepared, self.owner, self.preparing || running) {
|
||||
PlayPress::Nothing => play = disabled(play),
|
||||
PlayPress::Play => {
|
||||
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.picker_open = false;
|
||||
this.play();
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
PlayPress::PrepareThenPlay => {
|
||||
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.picker_open = false;
|
||||
this.play_when_prepared = this.selected().cloned();
|
||||
this.prepare(backend::MinecraftFrom::Install);
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
}
|
||||
let actions = div()
|
||||
.flex()
|
||||
@@ -2320,10 +2316,16 @@ impl Launcher {
|
||||
// Not prepared: an owner can have Horizon download Minecraft from Mojang; without an
|
||||
// owner there is nothing to play, so signing in comes first.
|
||||
let first = (!prepared && !self.preparing)
|
||||
.then_some(self.owner)
|
||||
.then_some(self.owner_shown())
|
||||
.flatten();
|
||||
// No launcher folder with Minecraft on this computer: an owner needs none.
|
||||
let no_install = self.settings.minecraft_dir.is_none();
|
||||
let resources_value = match first {
|
||||
_ if self.preparing => tr("Preparing…").to_string(),
|
||||
Some(true) if no_install => trf(
|
||||
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.",
|
||||
&[("version", &version)],
|
||||
),
|
||||
Some(true) => trf(
|
||||
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.",
|
||||
&[("version", &version)],
|
||||
@@ -2353,6 +2355,18 @@ impl Launcher {
|
||||
}));
|
||||
}
|
||||
let prepare = match first {
|
||||
Some(true) if no_install => div().child(
|
||||
button(
|
||||
"prepare-download",
|
||||
"Download and prepare",
|
||||
Kind::Secondary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.prepare(backend::MinecraftFrom::Mojang);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
Some(true) => div()
|
||||
.flex()
|
||||
.gap(px(8.))
|
||||
@@ -2386,12 +2400,15 @@ impl Launcher {
|
||||
),
|
||||
None => div().child(prepare),
|
||||
};
|
||||
let owner = self.owner == Some(true);
|
||||
let minecraft = self.settings.minecraft_dir.as_deref().map_or_else(
|
||||
|| {
|
||||
div()
|
||||
.child(tr(
|
||||
"Not found. Choose the folder where your launcher keeps Minecraft.",
|
||||
))
|
||||
.child(if owner {
|
||||
tr("None needed: Horizon downloads Minecraft from Mojang for your account.")
|
||||
} else {
|
||||
tr("Not found. Choose the folder where your launcher keeps Minecraft.")
|
||||
})
|
||||
.into_any_element()
|
||||
},
|
||||
|p| {
|
||||
@@ -2430,7 +2447,7 @@ impl Launcher {
|
||||
rows = rows.child(self.join_friend_row(prepared && !running, cx));
|
||||
// The top bar shows who plays; a row only when there is something to do about it:
|
||||
// no account that owns the game (Play needs one), or offline.
|
||||
if self.owner == Some(false) {
|
||||
if self.owner_shown() == Some(false) {
|
||||
rows = rows.child(index_row(
|
||||
5,
|
||||
tr("Microsoft account"),
|
||||
@@ -2782,7 +2799,7 @@ impl Launcher {
|
||||
.text_size(px(theme::CAPTION.px))
|
||||
.text_color(t.muted)
|
||||
.child(div().size(px(6.)).rounded_full().bg(t.accent))
|
||||
.child(format!("{} available", release.tag));
|
||||
.child(format!("{} available", update_name(release)));
|
||||
hero = hero.child(match installing {
|
||||
Some((_, p)) => line
|
||||
.child(
|
||||
@@ -2883,7 +2900,7 @@ impl Launcher {
|
||||
}),
|
||||
);
|
||||
let horizon = match (&self.updates.horizon, &self.update_error) {
|
||||
(Some(r), _) => trf("{version} available", &[("version", &r.tag)]),
|
||||
(Some(r), _) => trf("{version} available", &[("version", update_name(r))]),
|
||||
(None, Some(e)) => e.clone(),
|
||||
(None, None) if self.checked.is_some() => tr("Up to date").to_string(),
|
||||
_ => tr("Checking…").to_string(),
|
||||
@@ -3011,7 +3028,6 @@ impl Launcher {
|
||||
Some(toggle.into_any_element()),
|
||||
&t,
|
||||
))
|
||||
.child(self.nightly_row(n + 2, cx))
|
||||
.child(group("Launcher", "monitor"))
|
||||
.child(index_row(
|
||||
n + 3,
|
||||
@@ -3089,57 +3105,7 @@ impl Launcher {
|
||||
)
|
||||
.child(self.collection_section(cx))
|
||||
.children(self.agents_section(cx))
|
||||
}
|
||||
|
||||
/// Settings' Nightly row: off by default; on, the nightly build installs and updates in its
|
||||
/// own instance, and the release stays one click away (the Horizon instance).
|
||||
fn nightly_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let on = self.settings.nightly;
|
||||
let toggle =
|
||||
switch("nightly", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.set_nightly(!this.settings.nightly);
|
||||
cx.notify();
|
||||
}));
|
||||
let what = tr(
|
||||
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.",
|
||||
);
|
||||
let status = match (&self.nightly_progress, &self.nightly_error) {
|
||||
_ if !on => tr("Plays in its own Nightly instance: your worlds and the release stay as they are.")
|
||||
.to_string(),
|
||||
(Some(p), _) => trf(
|
||||
"Installing the nightly, {percent}%",
|
||||
&[("percent", &format!("{:.0}", p * 100.))],
|
||||
),
|
||||
(None, Some(e)) => trf("The nightly could not be installed: {error}", &[("error", e)]),
|
||||
(None, None) => match self
|
||||
.instances
|
||||
.list
|
||||
.iter()
|
||||
.find(|i| i.id == nightly::INSTANCE)
|
||||
.and_then(|i| i.version.clone())
|
||||
{
|
||||
Some(version) => trf(
|
||||
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.",
|
||||
&[("version", &version)],
|
||||
),
|
||||
None => tr("No nightly build yet.").to_string(),
|
||||
},
|
||||
};
|
||||
index_row(
|
||||
n,
|
||||
tr("Nightly builds"),
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(what.to_string())
|
||||
.child(status)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(toggle.into_any_element()),
|
||||
&t,
|
||||
)
|
||||
.children(self.dev_options_section(cx))
|
||||
}
|
||||
|
||||
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
|
||||
@@ -4413,6 +4379,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An owner's Play works on a version not prepared yet: it prepares it (downloading
|
||||
/// Minecraft from Mojang when this computer has no install), then plays. Without an owner
|
||||
/// it stays off: signing in comes first.
|
||||
#[test]
|
||||
fn play_prepares_first_for_an_owner() {
|
||||
use super::{play_press, PlayPress};
|
||||
assert_eq!(play_press(true, Some(true), false), PlayPress::Play);
|
||||
assert_eq!(play_press(true, None, false), PlayPress::Play);
|
||||
assert_eq!(
|
||||
play_press(false, Some(true), false),
|
||||
PlayPress::PrepareThenPlay
|
||||
);
|
||||
assert_eq!(play_press(false, Some(false), false), PlayPress::Nothing);
|
||||
assert_eq!(play_press(false, None, false), PlayPress::Nothing);
|
||||
assert_eq!(play_press(false, Some(true), true), PlayPress::Nothing);
|
||||
assert_eq!(play_press(true, Some(true), true), PlayPress::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_menu_has_quit() {
|
||||
let menus = super::menus();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+347
-19
@@ -5,7 +5,10 @@
|
||||
//! player's rights.
|
||||
//!
|
||||
//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and
|
||||
//! every mod of one build must pin the same one. The build compiles that revision's source, from
|
||||
//! every mod of one build must pin the same one. A mod that hooks only Horizon's native hook points
|
||||
//! (`game/native_hooks.rs`) names the version it needs instead (`hooks`): it builds on any Horizon
|
||||
//! whose hook points have that version or a later one (they are only ever added to), the player's own
|
||||
//! by default, so such mods always build together. The build compiles that revision's source, from
|
||||
//! GitHub, never a checkout on this computer, and installs as its own version,
|
||||
//! `native-<revision>-<key>` (the key hashes the revision, the mods' files, the tool's commit and
|
||||
//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a
|
||||
@@ -39,6 +42,16 @@ fn root() -> PathBuf {
|
||||
backend::data_dir().join("native")
|
||||
}
|
||||
|
||||
/// A canonical path without Windows' verbatim prefix: `canonicalize` gives `\\?\C:\...` there,
|
||||
/// and horizon-mixins' output check stats `\\?\C:`, the volume itself, which Windows refuses
|
||||
/// (os error 1). A drive path loses its `\\?\`; any other path is left as it is.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
match path.to_str().and_then(|p| p.strip_prefix(r"\\?\")) {
|
||||
Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest),
|
||||
_ => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// The build's log, kept until the next build.
|
||||
pub fn log_path() -> PathBuf {
|
||||
root().join("build.log")
|
||||
@@ -96,6 +109,9 @@ pub struct Bundle {
|
||||
pub horizon: Option<String>,
|
||||
/// The horizon-mixins commit it was tested with (its `.hnmod`'s).
|
||||
pub mixins: Option<String>,
|
||||
/// The native hook points' version it needs (its `.hnmod`'s `hooks`): then `horizon` is the
|
||||
/// revision it was tested on, and it builds on any with these hook points or later ones.
|
||||
pub hooks: Option<u32>,
|
||||
}
|
||||
|
||||
/// The bundles in `folder`: the folder itself when it is one (a `horizon-mixin.toml`, or an
|
||||
@@ -133,9 +149,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let native: serde_json::Value =
|
||||
let native_json: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let field = |name: &str| native[name].as_str().map(str::to_owned);
|
||||
let field = |name: &str| native_json[name].as_str().map(str::to_owned);
|
||||
let id = field("id").ok_or_else(|| format!("{} has no id.", path.display()))?;
|
||||
let found = bundle(&dir.join("mixins").join(&id), field("mixins"))?;
|
||||
match (&found.horizon, field("horizon")) {
|
||||
@@ -144,6 +160,9 @@ fn bundle_in(dir: &Path) -> Result<Option<Bundle>, String> {
|
||||
)),
|
||||
(_, native) => Ok(Some(Bundle {
|
||||
horizon: found.horizon.clone().or(native),
|
||||
hooks: native_json["hooks"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok()),
|
||||
..found
|
||||
})),
|
||||
}
|
||||
@@ -171,6 +190,7 @@ fn bundle(dir: &Path, mixins: Option<String>) -> Result<Bundle, String> {
|
||||
dir: dir.to_owned(),
|
||||
horizon: field("compatibility", "horizon_revision"),
|
||||
mixins,
|
||||
hooks: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +233,90 @@ fn agreed(
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// Where Horizon declares its native hook points' version, and the line that does.
|
||||
const HOOKS_FILE: &str = "crates/horizon-client/src/game/native_hooks.rs";
|
||||
const HOOKS_LINE: &str = "pub(crate) const NATIVE_HOOKS_API: u32 = ";
|
||||
|
||||
/// The native hook points' version in `HOOKS_FILE`'s text.
|
||||
fn hooks_in(source: &str) -> Option<u32> {
|
||||
let line = source.lines().find_map(|l| l.strip_prefix(HOOKS_LINE))?;
|
||||
line.trim_end().strip_suffix(';')?.parse().ok()
|
||||
}
|
||||
|
||||
/// The native hook points' version of Horizon at `revision`, from GitHub (None: it has none).
|
||||
fn hooks_at(revision: &str) -> Option<u32> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/{}/{revision}/{HOOKS_FILE}",
|
||||
backend::REPO
|
||||
);
|
||||
let text = backend::get(&url).ok()?.into_string().ok()?;
|
||||
hooks_in(&text)
|
||||
}
|
||||
|
||||
/// The revision to build `mods` on: `pinned` (the pins' agreement, else the installed version's)
|
||||
/// when every mod naming a hook points' version finds it or a later one there (`at` reads a
|
||||
/// revision's). Else, when nothing pins it, the revision those mods were tested on.
|
||||
fn with_hooks(
|
||||
mods: &[Bundle],
|
||||
pinned: String,
|
||||
free: bool,
|
||||
at: impl Fn(&str) -> Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let hooked: Vec<&Bundle> = mods.iter().filter(|b| b.hooks.is_some()).collect();
|
||||
let fits = |revision: &str| {
|
||||
let found = at(revision);
|
||||
hooked.iter().all(|b| found.is_some() && b.hooks <= found)
|
||||
};
|
||||
if hooked.is_empty() || fits(&pinned) {
|
||||
return Ok(pinned);
|
||||
}
|
||||
let tested: Vec<Bundle> = hooked.iter().map(|b| (*b).clone()).collect();
|
||||
let fallback = agreed(
|
||||
&tested,
|
||||
"Horizon",
|
||||
|b| b.horizon.as_ref(),
|
||||
|| Err(String::new()),
|
||||
);
|
||||
if let (true, Ok(revision)) = (free, fallback) {
|
||||
if fits(&revision) {
|
||||
return Ok(revision);
|
||||
}
|
||||
}
|
||||
let list: Vec<String> = hooked
|
||||
.iter()
|
||||
.map(|b| format!("{} (hook points v{})", b.id, b.hooks.unwrap_or_default()))
|
||||
.collect();
|
||||
Err(format!(
|
||||
"Horizon {} doesn't have the native hook points these mods use: {}. Update Horizon or the mods.",
|
||||
&pinned[..pinned.len().min(7)],
|
||||
list.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// A bundle that names a hook points' version builds on another revision than the one it was
|
||||
/// tested on: its `horizon_revision` (which horizon-mixins checks) becomes the build's.
|
||||
fn repin(bundle: &Bundle, revision: &str) -> Result<(), String> {
|
||||
let path = bundle.dir.join(MANIFEST);
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut value: toml::Table = text
|
||||
.parse()
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if let Some(toml::Value::Table(compat)) = value.get_mut("compatibility") {
|
||||
compat.insert("horizon_revision".into(), revision.into());
|
||||
}
|
||||
let text = toml::to_string(&value).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Whether Horizon's source at `source` has the `mods` feature (the WebAssembly mods' runtime,
|
||||
/// gone from later revisions).
|
||||
fn has_mods_feature(source: &Path) -> bool {
|
||||
let path = source.join("crates/horizon-client/Cargo.toml");
|
||||
let text = fs::read_to_string(path).unwrap_or_default();
|
||||
let value: toml::Table = text.parse().unwrap_or_default();
|
||||
value.get("features").and_then(|f| f.get("mods")).is_some()
|
||||
}
|
||||
|
||||
/// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub.
|
||||
pub fn revision_of(version: &str) -> Result<String, String> {
|
||||
let reference = match version.strip_prefix("nightly-") {
|
||||
@@ -388,6 +492,16 @@ fn tool(rust: &Toolchain, rev: &str) -> Result<PathBuf, String> {
|
||||
Ok(bin)
|
||||
}
|
||||
|
||||
/// `horizon-mixins apply`: the mods in `mods` applied to `source`, into `out`. It runs
|
||||
/// `cargo metadata`, so it gets the launcher's toolchain like a build does: on a computer with no
|
||||
/// Rust of its own, a plain command found no cargo (E_METADATA).
|
||||
fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) -> Command {
|
||||
let mut cmd = rust.command(tool);
|
||||
cmd.arg("apply").arg("--workspace").arg(source);
|
||||
cmd.arg("--mods").arg(mods).arg("--out").arg(out);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in
|
||||
/// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly
|
||||
/// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out:
|
||||
@@ -478,12 +592,19 @@ pub fn build(
|
||||
..b
|
||||
})
|
||||
.collect();
|
||||
let revision = agreed(
|
||||
&mods,
|
||||
// A mod naming a hook points' version doesn't pin: it follows the others, else the game.
|
||||
let pins: Vec<Bundle> = mods.iter().filter(|b| b.hooks.is_none()).cloned().collect();
|
||||
let free = pins.iter().all(|b| b.horizon.is_none());
|
||||
let pinned = agreed(
|
||||
&pins,
|
||||
"Horizon",
|
||||
|b| b.horizon.as_ref(),
|
||||
|| revision_of(base),
|
||||
)?;
|
||||
let revision = with_hooks(&mods, pinned, free, hooks_at)?;
|
||||
for b in mods.iter().filter(|b| b.hooks.is_some()) {
|
||||
repin(b, &revision)?;
|
||||
}
|
||||
let mixins = agreed(
|
||||
&mods,
|
||||
"horizon-mixins",
|
||||
@@ -500,27 +621,24 @@ pub fn build(
|
||||
let source = source(&revision)?;
|
||||
progress(Step::Mixins);
|
||||
// horizon-mixins refuses an output path through a link (macOS's /tmp is one).
|
||||
let real = root().canonicalize().map_err(|e| e.to_string())?;
|
||||
let real = plain(root().canonicalize().map_err(|e| e.to_string())?);
|
||||
let fresh = real.join("stage.new");
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let mut apply = Command::new(&tool);
|
||||
apply.arg("apply").arg("--workspace").arg(&source);
|
||||
apply.arg("--mods").arg(&incoming).arg("--out").arg(&fresh);
|
||||
run(apply, "horizon-mixins", |_| {})?;
|
||||
run(
|
||||
apply(&rust, &tool, &source, &incoming, &fresh),
|
||||
"horizon-mixins",
|
||||
|_| {},
|
||||
)?;
|
||||
let stage = root().join("stage");
|
||||
sync(&fresh, &stage)?;
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
let target = root().join("target");
|
||||
let mut compile = rust.command(&rust.cargo());
|
||||
compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target);
|
||||
compile.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--profile",
|
||||
PROFILE,
|
||||
"--features",
|
||||
FEATURES,
|
||||
]);
|
||||
compile.args(["build", "--locked", "--profile", PROFILE]);
|
||||
if has_mods_feature(&stage) {
|
||||
compile.args(["--features", FEATURES]);
|
||||
}
|
||||
compile.args(["-p", "horizon-client", "--bin", "horizon-client"]);
|
||||
let mut crates = 0;
|
||||
run(compile, "Compiling the game", |line| {
|
||||
@@ -535,6 +653,7 @@ pub fn build(
|
||||
"base": base,
|
||||
"horizon": revision,
|
||||
"horizon_mixins": mixins,
|
||||
"hooks": mods.iter().find_map(|m| m.hooks),
|
||||
"rust": rust_toolchain::VERSION,
|
||||
"mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::<Vec<_>>(),
|
||||
});
|
||||
@@ -543,7 +662,12 @@ pub fn build(
|
||||
}
|
||||
match instance.filter(|i| i.kind != instances::Kind::Default) {
|
||||
Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?,
|
||||
None => instance_on(&version)?,
|
||||
None => {
|
||||
let native = instance_on(&version)?;
|
||||
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
|
||||
carry_settings(&backend::game_home(), &native.home, &ids)?;
|
||||
native
|
||||
}
|
||||
};
|
||||
prune(&version);
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
@@ -600,6 +724,42 @@ fn instance_on(version: &str) -> Result<Instance, String> {
|
||||
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
|
||||
}
|
||||
|
||||
/// The mods' settings from the game's own home `from` (where their WebAssembly versions kept
|
||||
/// them) into the Native mods instance's `to`, for each mod `to` has none of yet: its storage
|
||||
/// folder (`horizon-mods/.horizon-mod-data/<id>/`) and its keys (`key.<id>.<name>:…` lines of
|
||||
/// `horizon-mods.txt`). Nothing is overwritten.
|
||||
fn carry_settings(from: &Path, to: &Path, ids: &[&str]) -> Result<(), String> {
|
||||
const DATA: &str = "horizon-mods/.horizon-mod-data";
|
||||
const LIST: &str = "horizon-mods.txt";
|
||||
let read = |home: &Path| fs::read_to_string(home.join(LIST)).unwrap_or_default();
|
||||
let (theirs, mut ours) = (read(from), read(to));
|
||||
let before = ours.len();
|
||||
for id in ids {
|
||||
let data = from.join(DATA).join(id);
|
||||
let target = to.join(DATA).join(id);
|
||||
if data.is_dir() && !target.exists() {
|
||||
backend::copy_dir(&data, &target).map_err(|e| format!("{}: {e}", data.display()))?;
|
||||
}
|
||||
let prefix = format!("key.{id}.");
|
||||
let has_keys = ours.lines().any(|l| l.starts_with(&prefix));
|
||||
for line in theirs
|
||||
.lines()
|
||||
.filter(|l| l.starts_with(&prefix) && !has_keys)
|
||||
{
|
||||
if !ours.is_empty() && !ours.ends_with('\n') {
|
||||
ours.push('\n');
|
||||
}
|
||||
ours += line;
|
||||
ours.push('\n');
|
||||
}
|
||||
}
|
||||
if ours.len() != before {
|
||||
fs::create_dir_all(to).map_err(|e| e.to_string())?;
|
||||
fs::write(to.join(LIST), ours).map_err(|e| format!("{LIST}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the native builds other than `keep` that no instance plays. The compiled crates
|
||||
/// (`native/target`) stay, so the next build only compiles what changed.
|
||||
pub fn prune(keep: &str) {
|
||||
@@ -625,6 +785,46 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_mods_instance_gets_the_mods_settings_once() {
|
||||
let (from, to) = (dir("carry-from"), dir("carry-to"));
|
||||
let data = from.join("horizon-mods/.horizon-mod-data");
|
||||
fs::create_dir_all(data.join("zoom")).unwrap();
|
||||
fs::write(data.join("zoom/level"), "8").unwrap();
|
||||
fs::create_dir_all(data.join("other")).unwrap();
|
||||
fs::write(
|
||||
from.join("horizon-mods.txt"),
|
||||
"mod.zoom:true\nkey.zoom.zoom:key.keyboard.c\nkey.other.x:key.keyboard.x\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
to.join("horizon-mods.txt"),
|
||||
"key.minimap.map:key.keyboard.m",
|
||||
)
|
||||
.unwrap();
|
||||
carry_settings(&from, &to, &["zoom", "minimap"]).unwrap();
|
||||
let ours = to.join("horizon-mods/.horizon-mod-data");
|
||||
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "8");
|
||||
assert!(!ours.join("other").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(to.join("horizon-mods.txt")).unwrap(),
|
||||
"key.minimap.map:key.keyboard.m\nkey.zoom.zoom:key.keyboard.c\n"
|
||||
);
|
||||
// Changed in the instance since: kept.
|
||||
fs::write(ours.join("zoom/level"), "4").unwrap();
|
||||
fs::write(
|
||||
from.join("horizon-mods.txt"),
|
||||
"key.zoom.zoom:key.keyboard.v\n",
|
||||
)
|
||||
.unwrap();
|
||||
carry_settings(&from, &to, &["zoom"]).unwrap();
|
||||
assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "4");
|
||||
assert!(fs::read_to_string(to.join("horizon-mods.txt"))
|
||||
.unwrap()
|
||||
.contains("key.zoom.zoom:key.keyboard.c"));
|
||||
let _ = (fs::remove_dir_all(&from), fs::remove_dir_all(&to));
|
||||
}
|
||||
|
||||
fn write_bundle(at: &Path, id: &str, helper: &str) {
|
||||
fs::create_dir_all(at).unwrap();
|
||||
let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n");
|
||||
@@ -632,6 +832,59 @@ mod tests {
|
||||
fs::write(at.join("hook.rs"), helper).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canonical_windows_path_loses_only_a_drives_verbatim_prefix() {
|
||||
let plain = |p: &str| plain(PathBuf::from(p));
|
||||
assert_eq!(
|
||||
plain(r"\\?\C:\Users\me\AppData\Roaming\horizon-launcher\native"),
|
||||
PathBuf::from(r"C:\Users\me\AppData\Roaming\horizon-launcher\native")
|
||||
);
|
||||
// A share has no drive form to fall back to; other paths have no prefix.
|
||||
assert_eq!(
|
||||
plain(r"\\?\UNC\server\share\native"),
|
||||
PathBuf::from(r"\\?\UNC\server\share\native")
|
||||
);
|
||||
assert_eq!(
|
||||
plain("/home/me/.local/share/horizon-launcher/native"),
|
||||
PathBuf::from("/home/me/.local/share/horizon-launcher/native")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_mixins_finds_the_launchers_cargo() {
|
||||
let rust = Toolchain {
|
||||
dir: PathBuf::from("/rust/1.99.1-x"),
|
||||
};
|
||||
let cmd = apply(
|
||||
&rust,
|
||||
Path::new("/tools/horizon-mixins"),
|
||||
Path::new("/src"),
|
||||
Path::new("/mods"),
|
||||
Path::new("/out"),
|
||||
);
|
||||
let path = cmd
|
||||
.get_envs()
|
||||
.find(|(k, _)| *k == "PATH")
|
||||
.and_then(|(_, v)| v)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::env::split_paths(path).next(),
|
||||
Some(rust.dir.join("bin"))
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"apply",
|
||||
"--workspace",
|
||||
"/src",
|
||||
"--mods",
|
||||
"/mods",
|
||||
"--out",
|
||||
"/out"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_one_bundle_or_a_folder_of_them() {
|
||||
let one = dir("one");
|
||||
@@ -721,6 +974,7 @@ mod tests {
|
||||
dir: PathBuf::new(),
|
||||
horizon: horizon.map(str::to_owned),
|
||||
mixins: None,
|
||||
hooks: None,
|
||||
};
|
||||
let never = || -> Result<String, String> { panic!("a pin wins") };
|
||||
fn get(b: &Bundle) -> Option<&String> {
|
||||
@@ -744,6 +998,80 @@ mod tests {
|
||||
assert!(agreed(&bad, "Horizon", get, never).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_on_the_hook_points_follow_the_game_or_the_pins() {
|
||||
assert_eq!(
|
||||
hooks_in("x\npub(crate) const NATIVE_HOOKS_API: u32 = 3;\n"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(hooks_in("const NATIVE_HOOKS_API: u32 = 3;"), None);
|
||||
let mod_ = |id: &str, horizon: &str, hooks: Option<u32>| Bundle {
|
||||
id: id.into(),
|
||||
version: "1".into(),
|
||||
dir: PathBuf::new(),
|
||||
horizon: Some(horizon.into()),
|
||||
mixins: None,
|
||||
hooks,
|
||||
};
|
||||
let c = "c".repeat(40);
|
||||
// A (the game) has hook points v1, B v2, C none.
|
||||
let at = |r: &str| match r.as_bytes()[0] {
|
||||
b'a' => Some(1),
|
||||
b'b' => Some(2),
|
||||
_ => None,
|
||||
};
|
||||
let zoom = [mod_("zoom", B, Some(1)), mod_("minimap", &c, Some(1))];
|
||||
assert_eq!(
|
||||
with_hooks(&zoom, A.into(), true, at).unwrap(),
|
||||
A,
|
||||
"the game's own"
|
||||
);
|
||||
let tested = [mod_("zoom", A, Some(1))];
|
||||
assert_eq!(
|
||||
with_hooks(&tested, c.clone(), true, at).unwrap(),
|
||||
A,
|
||||
"an older game"
|
||||
);
|
||||
assert_eq!(
|
||||
with_hooks(&tested, B.into(), false, at).unwrap(),
|
||||
B,
|
||||
"v1 mods on v2"
|
||||
);
|
||||
let newer = [mod_("recipes", B, Some(2))];
|
||||
let err = with_hooks(&newer, A.into(), false, at).unwrap_err();
|
||||
assert!(
|
||||
err.contains("aaaaaaa") && err.contains("recipes (hook points v2)"),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(
|
||||
with_hooks(&newer, A.into(), true, at).unwrap(),
|
||||
B,
|
||||
"its tested commit"
|
||||
);
|
||||
let pinned_only = [mod_("veinminer", B, None)];
|
||||
assert_eq!(with_hooks(&pinned_only, B.into(), false, at).unwrap(), B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hooks_bundle_is_read_and_repinned_to_the_build() {
|
||||
let d = dir("hooks");
|
||||
write_native(&d, "zoom", A, B);
|
||||
let json = fs::read_to_string(d.join(NATIVE_MANIFEST)).unwrap();
|
||||
let json = json.replacen('}', ",\"hooks\":1}", 1);
|
||||
fs::write(d.join(NATIVE_MANIFEST), json).unwrap();
|
||||
let found = bundles(&d).unwrap();
|
||||
assert_eq!(
|
||||
(found[0].hooks, found[0].horizon.as_deref()),
|
||||
(Some(1), Some(A))
|
||||
);
|
||||
repin(&found[0], B).unwrap();
|
||||
let again = bundle(&found[0].dir, None).unwrap();
|
||||
assert_eq!(
|
||||
(again.id.as_str(), again.horizon.as_deref()),
|
||||
("zoom", Some(B))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_changes_with_the_revision_and_any_file_of_the_mods() {
|
||||
let d = dir("key");
|
||||
|
||||
+71
-90
@@ -1,19 +1,17 @@
|
||||
//! Nightly (docs/release-flow.md "Nightly channel"): an opt-in build of main plus pull requests
|
||||
//! picked by trusted reviewers, some not reviewed yet, published as the `nightly` pre-release each
|
||||
//! night and when a reviewer asks. Off by default (`Settings::nightly`).
|
||||
//! Nightly (docs/release-flow.md): the only build Horizon ships since 2026-10-28, main plus the pull
|
||||
//! requests the trusted reviewers picked, published as the `nightly` pre-release a few times a day.
|
||||
//! Every launcher installs it as its update (`backend::horizon_update`), as its own version
|
||||
//! `nightly-<date>-<commit>`, beside the player's worlds, which it never touches. Its checksums must
|
||||
//! be signed for the nightly channel (`signed::nightly_digest`).
|
||||
//!
|
||||
//! It never touches the release: it installs as its own version, `nightly-<date>-<commit>`, and plays
|
||||
//! in its own instance, "Nightly" (`instances/nightly/` in the game's home), so the player's worlds
|
||||
//! stay where they are and choosing the Horizon instance plays the release again. Its checksums
|
||||
//! must be signed for the nightly channel (`signed::nightly_digest`); the release channel refuses
|
||||
//! them.
|
||||
use crate::backend::{self, Release, ASSET};
|
||||
use crate::instances::{self, Instance};
|
||||
//! Before that it was opt-in and played in its own "Nightly" instance (`instances/nightly/`):
|
||||
//! that instance, and its worlds, stay, and keep following the Nightly.
|
||||
use crate::backend::{self, Release};
|
||||
use crate::instances;
|
||||
use horizon_release_sign::NIGHTLY;
|
||||
|
||||
/// The Nightly instance's id (its folder in `instances/`) and name.
|
||||
/// The id of the instance (its folder in `instances/`) the opt-in Nightly played in.
|
||||
pub const INSTANCE: &str = "nightly";
|
||||
const NAME: &str = "Nightly";
|
||||
|
||||
/// The version a nightly installs as: `nightly-2026-10-28-0c02067`, from its title
|
||||
/// ("Nightly 2026-10-28 · 0c02067"), else its publication date.
|
||||
@@ -33,6 +31,12 @@ pub fn version_of(release: &Release) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The date of a nightly's version (`nightly-2026-10-28-0c02067`), or None for any other version.
|
||||
pub fn date_of(version: &str) -> Option<&str> {
|
||||
let date = version.strip_prefix("nightly-")?.get(..10)?;
|
||||
is_date(date).then_some(date)
|
||||
}
|
||||
|
||||
fn is_date(text: &str) -> bool {
|
||||
text.len() == 10
|
||||
&& text.chars().enumerate().all(|(i, c)| {
|
||||
@@ -44,112 +48,89 @@ fn is_date(text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The current nightly build, or None while there is none.
|
||||
pub fn latest() -> Result<Option<Release>, String> {
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/releases/tags/{NIGHTLY}",
|
||||
backend::REPO
|
||||
);
|
||||
let release: serde_json::Value = match backend::get(&url) {
|
||||
Ok(response) => response.into_json().map_err(|e| e.to_string())?,
|
||||
Err(e) if e.ends_with("answered 404") => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(parse(&release))
|
||||
}
|
||||
|
||||
fn parse(release: &serde_json::Value) -> Option<Release> {
|
||||
let list = serde_json::Value::Array(vec![release.clone()]);
|
||||
backend::parse_releases(&list, ASSET, |tag| tag == NIGHTLY)
|
||||
/// After `version`, a Nightly, installed as an update: the old Nightly instance follows it, and the
|
||||
/// older Nightlies go unless the selection or an instance still plays them. Worlds stay.
|
||||
pub fn after_update(version: &str) {
|
||||
let list = instances::list();
|
||||
if list
|
||||
.iter()
|
||||
.any(|i| i.id == INSTANCE && i.version.as_deref().is_some_and(|v| date_of(v).is_some()))
|
||||
{
|
||||
let _ = instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()));
|
||||
}
|
||||
let selected = backend::Settings::load().selected;
|
||||
let pinned: Vec<String> = instances::list()
|
||||
.into_iter()
|
||||
.next()
|
||||
.filter(|r| r.prerelease)
|
||||
}
|
||||
|
||||
/// The nightly to install: `latest` when it isn't installed yet.
|
||||
pub fn update(latest: Option<Release>, installed: &[String]) -> Option<Release> {
|
||||
latest.filter(|r| r.asset.is_some() && !installed.contains(&version_of(r)))
|
||||
}
|
||||
|
||||
/// Installs `release` (the current nightly) as its own version, points the Nightly instance at
|
||||
/// it (making the instance the first time) and removes older nightly versions. Returns the
|
||||
/// instance.
|
||||
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<Instance, String> {
|
||||
let sha256 = crate::signed::nightly_digest(
|
||||
ASSET.unwrap_or_default(),
|
||||
&horizon_release_sign::trusted_keys(),
|
||||
backend::fetch_small,
|
||||
)?;
|
||||
let version = version_of(release);
|
||||
backend::install_as(release, &version, &sha256, progress)?;
|
||||
let instance = instance_on(&version)?;
|
||||
for old in backend::installed() {
|
||||
if horizon_release_sign::is_nightly(&old) && old != version {
|
||||
let _ = backend::remove(&old);
|
||||
}
|
||||
.filter_map(|i| i.version)
|
||||
.collect();
|
||||
for old in stale(&backend::installed(), version, selected.as_deref(), &pinned) {
|
||||
let _ = backend::remove(&old);
|
||||
}
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// The Nightly instance, made empty the first time (with the player's options and offline name),
|
||||
/// playing `version`.
|
||||
fn instance_on(version: &str) -> Result<Instance, String> {
|
||||
if instances::find(INSTANCE).is_err() {
|
||||
instances::create(NAME, None)?;
|
||||
}
|
||||
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
|
||||
/// The installed Nightlies to remove once `current` is installed: every other one that is neither
|
||||
/// selected nor pinned by an instance.
|
||||
fn stale(
|
||||
installed: &[String],
|
||||
current: &str,
|
||||
selected: Option<&str>,
|
||||
pinned: &[String],
|
||||
) -> Vec<String> {
|
||||
installed
|
||||
.iter()
|
||||
.filter(|v| date_of(v).is_some() && *v != current && Some(v.as_str()) != selected)
|
||||
.filter(|v| !pinned.contains(v))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn release(name: &str, date: &str, asset: bool) -> Release {
|
||||
fn release(name: &str, date: &str) -> Release {
|
||||
Release {
|
||||
tag: NIGHTLY.into(),
|
||||
name: name.into(),
|
||||
body: String::new(),
|
||||
date: date.into(),
|
||||
asset: asset.then(|| ("https://example/horizon.zip".into(), 1)),
|
||||
asset: Some(("https://example/horizon.zip".into(), 1)),
|
||||
prerelease: true,
|
||||
nightly: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nightly_installs_under_its_date_and_commit() {
|
||||
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
|
||||
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28");
|
||||
assert_eq!(version_of(&r), "nightly-2026-10-28-0c02067");
|
||||
let untitled = release("Something else", "2026-10-29", true);
|
||||
let untitled = release("Something else", "2026-10-29");
|
||||
assert_eq!(version_of(&untitled), "nightly-2026-10-29");
|
||||
// Never a release version: the release channel's checks and lists leave it out.
|
||||
assert!(!backend::is_game_tag(&version_of(&r)));
|
||||
assert_eq!(date_of(&version_of(&r)), Some("2026-10-28"));
|
||||
assert_eq!(date_of("v0.2.95"), None);
|
||||
assert_eq!(date_of("nightly"), None);
|
||||
assert!(horizon_release_sign::is_nightly(&version_of(&r)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_new_nightly_with_a_build_here_is_an_update() {
|
||||
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
|
||||
let name = |r: Option<Release>| r.map(|r| r.name);
|
||||
fn only_unused_older_nightlies_are_removed() {
|
||||
let installed = [
|
||||
"nightly-2026-10-29-aaaaaaa",
|
||||
"nightly-2026-10-28-bbbbbbb",
|
||||
"nightly-2026-10-28-ccccccc",
|
||||
"nightly-2026-10-27-ddddddd",
|
||||
"v0.2.95",
|
||||
]
|
||||
.map(String::from);
|
||||
let pinned = ["nightly-2026-10-27-ddddddd".to_string()];
|
||||
assert_eq!(
|
||||
name(update(Some(r.clone()), &["v0.2.80".into()])),
|
||||
Some(r.name.clone())
|
||||
stale(
|
||||
&installed,
|
||||
"nightly-2026-10-29-aaaaaaa",
|
||||
Some("nightly-2026-10-28-ccccccc"),
|
||||
&pinned
|
||||
),
|
||||
["nightly-2026-10-28-bbbbbbb"]
|
||||
);
|
||||
let installed = ["nightly-2026-10-28-0c02067".to_string(), "v0.2.80".into()];
|
||||
assert!(update(Some(r), &installed).is_none());
|
||||
let no_build = release("Nightly 2026-10-29 · 1a2b3c4", "2026-10-29", false);
|
||||
assert!(update(Some(no_build), &installed).is_none());
|
||||
assert!(update(None, &installed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_nightly_pre_release_is_read() {
|
||||
let json = |tag: &str, pre: bool| {
|
||||
serde_json::json!({"tag_name": tag, "name": "Nightly 2026-10-28 · 0c02067",
|
||||
"prerelease": pre, "published_at": "2026-10-28T20:40:00Z",
|
||||
"assets": [{"name": ASSET.unwrap_or("none"), "browser_download_url": "u", "size": 1}]})
|
||||
};
|
||||
assert!(parse(&json("nightly", true)).is_some());
|
||||
assert!(parse(&json("nightly", false)).is_none());
|
||||
assert!(parse(&json("v0.2.81", true)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,21 @@ impl Launcher {
|
||||
/// Whether the screen covers the window: no owner, and either Play was refused or the player
|
||||
/// hasn't read it yet. The data question comes first.
|
||||
pub(crate) fn asking_owner(&self) -> bool {
|
||||
self.owner == Some(false)
|
||||
self.owner_shown() == Some(false)
|
||||
&& (self.owner_screen_open || !self.settings.owner_notice_seen)
|
||||
&& !self.asking_consent()
|
||||
&& self.opening.is_none()
|
||||
}
|
||||
|
||||
/// `owner` as the window shows it: the developer option that lets offline names play without
|
||||
/// an owner (`dev_options`) hides the gate, as if the keychain hadn't answered yet.
|
||||
pub(crate) fn owner_shown(&self) -> Option<bool> {
|
||||
match self.owner {
|
||||
Some(false) if self.settings.dev_offline => None,
|
||||
owner => owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the screen and starts the sign-in on it.
|
||||
pub(crate) fn sign_in_to_play(&mut self) {
|
||||
self.owner_screen_open = true;
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
//! folder. No rustup, nothing installed on the system or added to the PATH: the launcher calls this
|
||||
//! cargo with its own `CARGO_HOME`. The pins come from the channel manifest of [`VERSION`]
|
||||
//! (`https://static.rust-lang.org/dist/channel-rust-<VERSION>.toml`, `[pkg.<name>.target.<host>]`).
|
||||
//!
|
||||
//! On Windows it also brings MinGW-w64 ([`MINGW`]): `rust-mingw` only links. The game needs a C
|
||||
//! compiler (`ring`, `hidapi` and wasmtime build C with `cc`), and `windows-sys`' `raw-dylib`
|
||||
//! imports need `dlltool`, which runs the GNU assembler; `rust-mingw` has neither.
|
||||
use crate::backend;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -22,8 +26,27 @@ const DIST: &str = "https://static.rust-lang.org/dist/2026-10-15";
|
||||
pub struct Host {
|
||||
pub target: &'static str,
|
||||
components: &'static [(&'static str, &'static str)],
|
||||
/// The C toolchain it brings besides Rust's components, unpacked into [`MINGW_DIR`].
|
||||
mingw: Option<&'static Mingw>,
|
||||
}
|
||||
|
||||
/// A MinGW-w64 release: one zip, pinned by its SHA-256.
|
||||
pub struct Mingw {
|
||||
pub version: &'static str,
|
||||
url: &'static str,
|
||||
sha256: &'static str,
|
||||
}
|
||||
|
||||
/// WinLibs' build of GCC and binutils on MinGW-w64 (MSVCRT, the C runtime `rust-mingw` links
|
||||
/// against; POSIX threads; SEH), the release's `mingw64/` folder without its documentation.
|
||||
pub const MINGW: Mingw = Mingw {
|
||||
version: "GCC 16.2.0, MinGW-w64 14.0.0 (WinLibs r3)",
|
||||
url: "https://github.com/brechtsanders/winlibs_mingw/releases/download/16.2.0posix-23.1.3-14.0.0-msvcrt-r3/winlibs-x86_64-posix-seh-gcc-16.2.0-mingw-w64msvcrt-14.0.0-r3.zip",
|
||||
sha256: "62af32ea58acdea1318ea5dd8eb7017eaee694dd40b269d52a59c769dcfefb82",
|
||||
};
|
||||
/// Where MinGW-w64 goes in the toolchain, as the zip names it.
|
||||
const MINGW_DIR: &str = "mingw64";
|
||||
|
||||
const HOSTS: &[Host] = &[
|
||||
Host {
|
||||
target: "aarch64-apple-darwin",
|
||||
@@ -41,6 +64,7 @@ const HOSTS: &[Host] = &[
|
||||
"c124006d63885ba1029aca49efd59e93be6b2b171c379318ff94a00ef5eba664",
|
||||
),
|
||||
],
|
||||
mingw: None,
|
||||
},
|
||||
Host {
|
||||
target: "x86_64-unknown-linux-gnu",
|
||||
@@ -58,6 +82,7 @@ const HOSTS: &[Host] = &[
|
||||
"d1290c05994a59c029e562cb75be1bddc6fb11a4c501e1b7b0f61d119c6cbb12",
|
||||
),
|
||||
],
|
||||
mingw: None,
|
||||
},
|
||||
Host {
|
||||
target: "x86_64-pc-windows-gnu",
|
||||
@@ -79,6 +104,7 @@ const HOSTS: &[Host] = &[
|
||||
"30263d7bb4fd0bf63a153afe36d7fc102f929a7d42781651c6b3a03a414ba428",
|
||||
),
|
||||
],
|
||||
mingw: Some(&MINGW),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -109,13 +135,21 @@ impl Toolchain {
|
||||
self.dir.join("bin").join(exe("cargo"))
|
||||
}
|
||||
|
||||
/// MinGW-w64's programs (gcc, as, dlltool), when the toolchain brings it (Windows).
|
||||
pub fn mingw_bin(&self) -> PathBuf {
|
||||
self.dir.join(MINGW_DIR).join("bin")
|
||||
}
|
||||
|
||||
/// `program` (this toolchain's cargo, or a tool built with it) set up to use only this
|
||||
/// toolchain: its rustc, the launcher's Cargo home, and none of the player's rustup or
|
||||
/// compiler-wrapper settings.
|
||||
/// toolchain: its rustc, its MinGW-w64 where it has one, the launcher's Cargo home, and none of
|
||||
/// the player's rustup or compiler-wrapper settings.
|
||||
pub fn command(&self, program: &Path) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let paths = std::iter::once(self.dir.join("bin")).chain(std::env::split_paths(&path));
|
||||
let mingw = Some(self.mingw_bin()).filter(|bin| bin.is_dir());
|
||||
let paths = std::iter::once(self.dir.join("bin"))
|
||||
.chain(mingw)
|
||||
.chain(std::env::split_paths(&path));
|
||||
cmd.env("PATH", std::env::join_paths(paths).unwrap_or(path))
|
||||
.env("RUSTC", self.dir.join("bin").join(exe("rustc")))
|
||||
.env("CARGO_HOME", cargo_home());
|
||||
@@ -164,10 +198,13 @@ fn dir_for(host: &Host) -> PathBuf {
|
||||
|
||||
const COMPLETE: &str = ".installed";
|
||||
|
||||
/// The toolchain, when it is installed.
|
||||
/// The toolchain, when it is installed. A Windows one from before MinGW-w64 isn't: installing
|
||||
/// again brings it.
|
||||
pub fn installed() -> Option<Toolchain> {
|
||||
let dir = dir_for(host()?);
|
||||
dir.join(COMPLETE).is_file().then_some(Toolchain { dir })
|
||||
let host = host()?;
|
||||
let toolchain = Toolchain { dir: dir_for(host) };
|
||||
let mingw = host.mingw.is_none() || toolchain.mingw_bin().join(exe("gcc")).is_file();
|
||||
(toolchain.dir.join(COMPLETE).is_file() && mingw).then_some(toolchain)
|
||||
}
|
||||
|
||||
/// Downloads, checks and unpacks the pinned toolchain (once); `progress` gets the fraction done.
|
||||
@@ -186,7 +223,7 @@ pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
|
||||
let partial = root().join(format!(".{VERSION}-{}.partial", host.target));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
let count = host.components.len() as f32;
|
||||
let count = (host.components.len() + usize::from(host.mingw.is_some())) as f32;
|
||||
for (i, (name, sha256)) in host.components.iter().enumerate() {
|
||||
let file = format!("{name}-{VERSION}-{}.tar.gz", host.target);
|
||||
let archive = root().join(format!(".{file}"));
|
||||
@@ -197,6 +234,16 @@ pub fn install(progress: impl Fn(f32)) -> Result<Toolchain, String> {
|
||||
let _ = fs::remove_file(&archive);
|
||||
unpacked.map_err(|e| format!("Rust {VERSION} ({name}): {e}"))?;
|
||||
}
|
||||
if let Some(mingw) = host.mingw {
|
||||
let archive = root().join(".mingw.zip");
|
||||
let done = host.components.len() as f32;
|
||||
let step = |f: f32| progress((done + f) / count);
|
||||
backend::download(mingw.url, 0, mingw.sha256, &archive, &step)
|
||||
.map_err(|e| format!("MinGW-w64 ({}): {e}", mingw.version))?;
|
||||
let unpacked = unpack_mingw(&archive, &partial);
|
||||
let _ = fs::remove_file(&archive);
|
||||
unpacked.map_err(|e| format!("MinGW-w64 ({}): {e}", mingw.version))?;
|
||||
}
|
||||
fs::write(partial.join(COMPLETE), VERSION).map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&partial, &dir).map_err(|e| e.to_string())?;
|
||||
@@ -246,9 +293,50 @@ fn component_path(path: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpacks MinGW-w64's zip into `prefix`: its `mingw64/` folder, documentation left out.
|
||||
fn unpack_mingw(archive: &Path, prefix: &Path) -> Result<(), String> {
|
||||
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(relative) = entry.enclosed_name().and_then(|p| mingw_path(&p)) else {
|
||||
continue;
|
||||
};
|
||||
let to = prefix.join(&relative);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&to).map_err(|e| e.to_string())?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut out = fs::File::create(&to).map_err(|e| format!("{}: {e}", relative.display()))?;
|
||||
std::io::copy(&mut entry, &mut out).map_err(|e| format!("{}: {e}", relative.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where a file of MinGW-w64's zip goes, relative to the prefix: `mingw64/bin/gcc.exe` as it is.
|
||||
/// None for its documentation (`mingw64/share/`, 50 MB of the 925), the folder itself and anything
|
||||
/// outside `mingw64/`.
|
||||
fn mingw_path(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let parts: Vec<&std::ffi::OsStr> = path
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(name) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
match parts.as_slice() {
|
||||
[top, first, ..] if *top == MINGW_DIR && *first != "share" => Some(parts.iter().collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this computer can link what the toolchain compiles: Rust brings its compiler, but uses
|
||||
/// the system's linker on macOS (the Command Line Tools) and Linux (`cc`). Windows' `windows-gnu`
|
||||
/// toolchain brings its own.
|
||||
/// toolchain brings its own, and MinGW-w64 for C and import libraries ([`MINGW`]).
|
||||
pub fn linker() -> Result<(), String> {
|
||||
let ok = |program: &str, args: &[&str]| {
|
||||
Command::new(program)
|
||||
@@ -307,6 +395,17 @@ mod tests {
|
||||
let windows = HOSTS.iter().find(|h| h.target.contains("windows")).unwrap();
|
||||
assert!(windows.target.ends_with("-gnu"), "no Visual Studio needed");
|
||||
assert!(windows.components.iter().any(|c| c.0 == "rust-mingw"));
|
||||
// Only Windows brings a C toolchain; macOS and Linux use the system's.
|
||||
for host in HOSTS {
|
||||
assert_eq!(host.mingw.is_some(), host.target.contains("windows"));
|
||||
}
|
||||
let mingw = windows.mingw.unwrap();
|
||||
assert!(mingw.sha256.len() == 64 && mingw.sha256.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
assert!(mingw.url.starts_with("https://") && mingw.url.ends_with(".zip"));
|
||||
assert!(
|
||||
mingw.url.contains("msvcrt"),
|
||||
"the C runtime rust-mingw links against"
|
||||
);
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
assert_eq!(host().unwrap().target, "aarch64-apple-darwin");
|
||||
}
|
||||
@@ -404,4 +503,73 @@ mod tests {
|
||||
);
|
||||
assert!(get("CARGO_HOME").flatten().unwrap().ends_with("cargo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mingw_files_land_under_mingw64_without_the_documentation() {
|
||||
let at = |p: &str| mingw_path(Path::new(p));
|
||||
assert_eq!(
|
||||
at("mingw64/bin/gcc.exe"),
|
||||
Some(PathBuf::from("mingw64/bin/gcc.exe"))
|
||||
);
|
||||
assert_eq!(
|
||||
at("mingw64/libexec/gcc/x86_64-w64-mingw32/16.2.0/cc1.exe"),
|
||||
Some(PathBuf::from(
|
||||
"mingw64/libexec/gcc/x86_64-w64-mingw32/16.2.0/cc1.exe"
|
||||
))
|
||||
);
|
||||
for skipped in [
|
||||
"mingw64",
|
||||
"mingw64/share/doc/gcc.html",
|
||||
"other/bin/gcc.exe",
|
||||
"mingw64/../evil.exe",
|
||||
"/mingw64/bin/gcc.exe",
|
||||
] {
|
||||
assert_eq!(at(skipped), None, "{skipped}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mingw_zip_unpacks_its_programs_and_not_its_documentation() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mingw-unpack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let archive = dir.join("mingw.zip");
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&archive).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.add_directory("mingw64/bin/", options).unwrap();
|
||||
for (path, body) in [
|
||||
("mingw64/bin/gcc.exe", "gcc"),
|
||||
("mingw64/share/doc/readme.txt", "docs"),
|
||||
("licenses/readme.txt", "outside"),
|
||||
] {
|
||||
zip.start_file(path, options).unwrap();
|
||||
zip.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let prefix = dir.join("prefix");
|
||||
unpack_mingw(&archive, &prefix).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(prefix.join("mingw64/bin/gcc.exe")).unwrap(),
|
||||
"gcc"
|
||||
);
|
||||
assert!(!prefix.join("mingw64/share").exists() && !prefix.join("licenses").exists());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_puts_mingw_after_rust_when_the_toolchain_has_it() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mingw-path-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let toolchain = Toolchain { dir: dir.clone() };
|
||||
fs::create_dir_all(toolchain.mingw_bin()).unwrap();
|
||||
let cmd = toolchain.command(&toolchain.cargo());
|
||||
let path = cmd
|
||||
.get_envs()
|
||||
.find(|(k, _)| *k == "PATH")
|
||||
.and_then(|(_, v)| v)
|
||||
.unwrap();
|
||||
let first: Vec<PathBuf> = std::env::split_paths(path).take(2).collect();
|
||||
assert_eq!(first, [dir.join("bin"), toolchain.mingw_bin()]);
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ fn trash_stopped_server(
|
||||
.map_err(|e| e.to_string())?;
|
||||
lock.try_lock()
|
||||
.map_err(|_| "Stop the server before deleting it.".to_string())?;
|
||||
// Windows can't move a folder while a file in it is open, the lock included: let it go
|
||||
// first. Still safe: a supervisor starting in between opens the lock again, and then the
|
||||
// move fails instead of taking a live server.
|
||||
#[cfg(windows)]
|
||||
drop(lock);
|
||||
trash(dir).map_err(|e| format!("Could not move the server to the trash: {e}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,15 @@ pub fn digest(
|
||||
horizon_release_sign::signed_digest(tag, asset, sums.as_deref(), signature.as_deref(), keys)
|
||||
}
|
||||
|
||||
/// The signed SHA-256 of `asset` in the current nightly build (the `nightly` pre-release): its
|
||||
/// checksums must be signed for the nightly channel (`horizon_release_sign::nightly_digest`).
|
||||
/// The signed SHA-256 of `asset` in the Nightly published under `tag` (`nightly`, or a dated
|
||||
/// `nightly-<date>`): its checksums must be signed for the nightly channel
|
||||
/// (`horizon_release_sign::nightly_digest`), whatever tag serves them.
|
||||
pub fn nightly_digest(
|
||||
tag: &str,
|
||||
asset: &str,
|
||||
keys: &[PublicKey],
|
||||
fetch: impl Fn(&str) -> Result<Option<Vec<u8>>, String>,
|
||||
) -> Result<String, String> {
|
||||
let tag = horizon_release_sign::NIGHTLY;
|
||||
let sums = fetch(&asset_url(tag, SUMS))?;
|
||||
let signature = fetch(&asset_url(tag, SIGNATURE))?
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
|
||||
@@ -131,7 +132,7 @@ mod tests {
|
||||
};
|
||||
let nightly = serving(horizon_release_sign::NIGHTLY);
|
||||
assert_eq!(
|
||||
nightly_digest("horizon.zip", &keys, &nightly),
|
||||
nightly_digest("nightly", "horizon.zip", &keys, &nightly),
|
||||
Ok(sha256_hex(b"game"))
|
||||
);
|
||||
for tag in ["v0.2.81", "nightly", "nightly-2026-10-27"] {
|
||||
@@ -142,6 +143,6 @@ mod tests {
|
||||
}
|
||||
let release = serving("v0.2.81");
|
||||
assert!(digest("v0.2.81", "horizon.zip", &keys, &release).is_ok());
|
||||
assert!(nightly_digest("horizon.zip", &keys, &release).is_err());
|
||||
assert!(nightly_digest("nightly", "horizon.zip", &keys, &release).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Launcher {
|
||||
pub(super) fn play_as(&mut self, who: Player) {
|
||||
let mut accounts = Accounts::load();
|
||||
let done = accounts
|
||||
.play_as(account::store(), who)
|
||||
.play_as_with(account::store(), who, self.settings.dev_offline)
|
||||
.and_then(|()| accounts.save());
|
||||
match done {
|
||||
Ok(()) => self.accounts = accounts,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Installs horizon-server (docs/server.md) on Windows: the latest Horizon release, downloaded from GitHub
|
||||
# Releases and checked against the SHA-256 GitHub publishes for it.
|
||||
# Installs horizon-server (docs/server.md) on Windows: the current Horizon Nightly (the only build Horizon ships;
|
||||
# GitHub's latest release while there is none), downloaded from GitHub Releases and checked against the SHA-256
|
||||
# GitHub publishes for it.
|
||||
#
|
||||
# irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
|
||||
# & ([scriptblock]::Create((irm .../install.ps1))) -Version v0.5.0 -Dir D:\Horizon
|
||||
@@ -20,10 +21,21 @@ if ($env:PROCESSOR_ARCHITECTURE -ne "AMD64") {
|
||||
throw "No Horizon release for Windows on $env:PROCESSOR_ARCHITECTURE yet: build it from source (docs/server.md)"
|
||||
}
|
||||
|
||||
$Api = if ($Version -eq "latest") { "https://api.github.com/repos/$Repo/releases/latest" }
|
||||
else { "https://api.github.com/repos/$Repo/releases/tags/$Version" }
|
||||
$Release = Invoke-RestMethod -Uri $Api -Headers @{ Accept = "application/vnd.github+json" }
|
||||
function Get-Release([string]$Path) {
|
||||
Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/$Path" -Headers @{ Accept = "application/vnd.github+json" }
|
||||
}
|
||||
if ($Version -eq "latest") {
|
||||
try { $Release = Get-Release "tags/nightly" } catch { $Release = Get-Release "latest" }
|
||||
} else {
|
||||
$Release = Get-Release "tags/$Version"
|
||||
}
|
||||
$Tag = $Release.tag_name
|
||||
# A Nightly installs under its date and commit, as `horizon-server update` names it:
|
||||
# "Nightly 2026-10-28 <middle dot> 0c02067" is nightly-2026-10-28-0c02067 ([char]0xB7: this file stays ASCII).
|
||||
if (($Tag -eq "nightly" -or $Tag.StartsWith("nightly-")) -and
|
||||
$Release.name -match "^Nightly (\d{4}-\d{2}-\d{2}) $([char]0xB7) ([0-9a-f]{1,40})$") {
|
||||
$Tag = "nightly-$($Matches[1])-$($Matches[2])"
|
||||
}
|
||||
$Item = $Release.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
|
||||
if (-not $Item) { throw "Release $Tag has no $Asset" }
|
||||
if (-not $Item.digest -or -not $Item.digest.StartsWith("sha256:")) { throw "Release $Tag publishes no SHA-256 for $Asset" }
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user