mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3149d43ef | ||
|
|
e1a7892361 | ||
|
|
e9f93c656a | ||
|
|
eec2e87a15 | ||
|
|
373d9c08c0 | ||
|
|
911ca721de | ||
|
|
5034e4557d | ||
|
|
40c08c2d12 | ||
|
|
73da2f04dd | ||
|
|
39efe6ef39 | ||
|
|
29c09b4bf2 | ||
|
|
2189baea6d | ||
|
|
836c1ee3b9 | ||
|
|
cc7534341c | ||
|
|
21d8e53034 | ||
|
|
6df37324f5 | ||
|
|
d58d26c793 | ||
|
|
93e3096e06 | ||
|
|
e05933169a | ||
|
|
bb3497bea0 | ||
|
|
6ef8deedca | ||
|
|
d63d0c1744 | ||
|
|
f5e97deac2 | ||
|
|
116a7f7dca | ||
|
|
31e9e2da97 | ||
|
|
74768d2eeb | ||
|
|
a4a709c2d6 | ||
|
|
b4345b1c2b | ||
|
|
ed11f614f8 | ||
|
|
da0056c248 | ||
|
|
2364bd3504 | ||
|
|
82b0051a3c | ||
|
|
e0abe90c5c | ||
|
|
982034f5f8 | ||
|
|
2761349d98 | ||
|
|
21d8b0702c | ||
|
|
d0f61d07cf | ||
|
|
3d8a8f7d02 | ||
|
|
9ec5f1ddbc | ||
|
|
90eda915d5 | ||
|
|
bdbc816486 | ||
|
|
72b9daabf0 | ||
|
|
f324251563 | ||
|
|
5ee1130719 | ||
|
|
6443847633 | ||
|
|
83e8cbc4b6 | ||
|
|
b729cfc833 | ||
|
|
8942d00ace | ||
|
|
0609fb2beb | ||
|
|
e37770bbfe | ||
|
|
70aff6e90b | ||
|
|
b9d2d93ccb | ||
|
|
df23e1bfed | ||
|
|
fdfa3d568d | ||
|
|
d9360338f6 | ||
|
|
feec1b67e8 | ||
|
|
bf81ae6358 | ||
|
|
c5a640dd56 | ||
|
|
4bfa496173 | ||
|
|
4b5fa2ea6b | ||
|
|
a9e6818d2a | ||
|
|
b457f6382a | ||
|
|
41b799042a | ||
|
|
40c77c02f0 | ||
|
|
1437537cbf | ||
|
|
c879c22a6a | ||
|
|
39904c8051 | ||
|
|
e7ca664e01 | ||
|
|
11035336a0 | ||
|
|
c99de82044 | ||
|
|
5262effa7e | ||
|
|
c2c8df4542 | ||
|
|
f08546fd74 | ||
|
|
272e230371 | ||
|
|
90ae953747 | ||
|
|
da150409ea | ||
|
|
372f918e44 | ||
|
|
d4b2e24535 |
No files matched your search
@@ -3,7 +3,8 @@ name: checks
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
on:
|
||||
@@ -72,8 +73,37 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
parity:
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
parity-scope:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: scope
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
|
||||
run: |
|
||||
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
|
||||
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
|
||||
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No harness, oracle, probe or runner change: parity-main checks this on main"
|
||||
fi
|
||||
parity:
|
||||
needs: parity-scope
|
||||
if: needs.parity-scope.outputs.run == 'true'
|
||||
uses: ./.github/workflows/parity.yml
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: parity-main
|
||||
# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
|
||||
# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
|
||||
# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
|
||||
# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
|
||||
# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# The comment on the merge that broke a harness.
|
||||
pull-requests: write
|
||||
# tools/parity_main.py base reads earlier runs.
|
||||
actions: read
|
||||
concurrency:
|
||||
group: parity-main
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: Main's last green parity-main
|
||||
id: base
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: The merge that broke them
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -37,13 +37,56 @@ jobs:
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
run: |
|
||||
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
|
||||
# branch made before a better selection landed gets it too; the head's own runner then runs those
|
||||
# harnesses. A branch that changes the selection itself selects with its own code.
|
||||
set -euo pipefail
|
||||
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
|
||||
fork_point=$(git merge-base "$BASE_SHA" HEAD)
|
||||
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
|
||||
own
|
||||
exit
|
||||
fi
|
||||
had_reach=$(git ls-files tools/parity_reach.py)
|
||||
git show origin/main:tools/parity.py > tools/parity.py
|
||||
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
|
||||
status=0
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
|
||||
git checkout -- tools/parity.py
|
||||
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
|
||||
cat .local/parity-selection.txt
|
||||
if [ "$status" != 0 ]; then
|
||||
echo "main's selection failed: the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
|
||||
# The summary line says how many would run: a count that doesn't match the names read (a format change)
|
||||
# must not let the job pass untested.
|
||||
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
|
||||
count=$(printf '%s' "$names" | grep -c . || true)
|
||||
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
|
||||
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
if [ "$count" = 0 ]; then
|
||||
echo "Nothing to run."
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
python3 tools/parity.py $names --timeout 2400
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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:
|
||||
# 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.
|
||||
@@ -18,7 +19,8 @@ name: release
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [checks]
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
workflows: [checks, parity-main]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
schedule:
|
||||
@@ -64,8 +66,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
|
||||
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
|
||||
# main's newest commit with green checks.
|
||||
# The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
|
||||
# otherwise main's newest commit with green checks and parity-main.
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
run: |
|
||||
|
||||
@@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
|
||||
Generated
+7
@@ -2733,6 +2733,13 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+32
-13
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included.
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -374,6 +374,16 @@ More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### A server you install with one command
|
||||
|
||||
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -790,18 +800,6 @@ Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds
|
||||
|
||||
</details>
|
||||
|
||||
#### OpenGL for old computers
|
||||
|
||||
A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.
|
||||
|
||||
More: [docs/graphics.md](docs/graphics.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -812,6 +810,16 @@ No official build. Android was first closed as not planned, with touch controls,
|
||||
|
||||
</details>
|
||||
|
||||
#### AppImage and a Nix flake
|
||||
|
||||
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### A complete video studio inside the game
|
||||
@@ -824,6 +832,16 @@ Three stages. Recording: capture gameplay in a keypress with an encoder thread a
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
|
||||
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### An optional AI teammate
|
||||
@@ -936,6 +954,7 @@ Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
|
||||
@@ -224,6 +224,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
|
||||
//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
|
||||
use horizon_client::{
|
||||
item_data::parse_stack,
|
||||
item_tooltip::{TooltipDisplay, lore_texts},
|
||||
};
|
||||
use std::{error::Error, fmt::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let mut output = String::new();
|
||||
for line in input
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
{
|
||||
let stack = parse_stack(line)?.ok_or("empty stack")?;
|
||||
let display = TooltipDisplay::for_stack(&stack);
|
||||
if !display.shows("minecraft:lore") {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let lines = lore_texts(&stack);
|
||||
if lines.is_empty() {
|
||||
writeln!(output, "-")?;
|
||||
continue;
|
||||
}
|
||||
let mut rendered = Vec::new();
|
||||
for text in lines {
|
||||
let mut spans = Vec::new();
|
||||
for span in &text.spans {
|
||||
if span.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = &span.style;
|
||||
spans.push(format!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
span.text,
|
||||
style.color.map(|c| c as i32).unwrap_or(-1),
|
||||
style.bold == Some(true),
|
||||
style.italic == Some(true),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
style.obfuscated == Some(true),
|
||||
));
|
||||
}
|
||||
rendered.push(spans.join(" | "));
|
||||
}
|
||||
writeln!(output, "{}", rendered.join(" || "))?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ mod input_probe;
|
||||
mod interaction_input_fixture;
|
||||
mod lan_probe;
|
||||
mod light_parity;
|
||||
mod lore_tooltip_fixture;
|
||||
mod marker_particles_probe;
|
||||
mod menu_fixture;
|
||||
mod mipmap_probe;
|
||||
@@ -133,6 +134,7 @@ probes! {
|
||||
interaction_input_fixture,
|
||||
lan_probe,
|
||||
light_parity,
|
||||
lore_tooltip_fixture,
|
||||
marker_particles_probe,
|
||||
menu_fixture,
|
||||
mipmap_probe,
|
||||
|
||||
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
recipes_used: Default::default(),
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
|
||||
@@ -63,6 +63,10 @@ enum Animate {
|
||||
SculkSensor,
|
||||
/// Potent sulfur that is not dry.
|
||||
PotentSulfur,
|
||||
DriedGhast {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -137,6 +141,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
drag: prop("drag").as_deref() == Some("true"),
|
||||
},
|
||||
"fire" | "soul_fire" => Animate::Fire,
|
||||
"dried_ghast" => Animate::DriedGhast {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -180,6 +188,8 @@ pub struct AnimateTables {
|
||||
impermeable: Vec<bool>,
|
||||
power_provider: Vec<bool>,
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
}
|
||||
@@ -237,6 +247,7 @@ impl AnimateTables {
|
||||
impermeable: by_state("minecraft:impermeable"),
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
ambient,
|
||||
})
|
||||
}
|
||||
@@ -869,6 +880,45 @@ fn block_animate_tick(
|
||||
];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::DriedGhast { waterlogged } => {
|
||||
// DriedGhastBlock.animateTick.
|
||||
let c = [fx + 0.5, fy + 0.5, fz + 0.5];
|
||||
if waterlogged {
|
||||
if next_int(engine.random(), 40) == 0 {
|
||||
let name = "minecraft:block.dried_ghast.ambient_water";
|
||||
sound(engine, name, c, 1.0, 1.0);
|
||||
}
|
||||
let r = engine.random();
|
||||
if next_int(r, 6) == 0 {
|
||||
let x = c[0] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
|
||||
let y = c[1] + 0.4;
|
||||
let z = c[2] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
|
||||
let v = [0.0, f64::from(r.next_float()), 0.0];
|
||||
add(engine, T::HappyVillager, [x, y, z], v);
|
||||
}
|
||||
} else {
|
||||
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
|
||||
let triggers = || {
|
||||
tables
|
||||
.dried_ghast_sounds
|
||||
.get(below.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if next_int(engine.random(), 40) == 0 && triggers() {
|
||||
sound(engine, "minecraft:block.dried_ghast.ambient", c, 1.0, 1.0);
|
||||
}
|
||||
if next_int(engine.random(), 6) == 0 {
|
||||
add(engine, T::WhiteSmoke, c, [0.0, 0.02, 0.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EndPortal => {
|
||||
// EndPortalBlock.animateTick: one smoke particle above the surface.
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::EnderChest => {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Furnace {
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
@@ -190,6 +194,7 @@ impl BlockEntity {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
@@ -483,6 +488,14 @@ impl Furnace {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
@@ -564,6 +577,7 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -594,3 +608,41 @@ fn total_time(time: u32, speed: f32) -> i32 {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
count: u32,
|
||||
experience: f32,
|
||||
random: &mut impl horizon_core::random::RandomSource,
|
||||
) -> u32 {
|
||||
let total = count as f32 * experience;
|
||||
let whole = total.floor();
|
||||
let fraction = total - whole;
|
||||
let mut xp = whole.max(0.0) as u32;
|
||||
if fraction != 0.0 && random.next_float() < fraction {
|
||||
xp += 1;
|
||||
}
|
||||
xp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut untouched = LegacyRandom::new(7);
|
||||
assert_eq!(create_experience(4, 0.5, &mut random), 2);
|
||||
assert_eq!(create_experience(3, 0.0, &mut random), 0);
|
||||
assert_eq!(random.next_long(), untouched.next_long());
|
||||
let paid: u32 = (0..1000)
|
||||
.map(|_| create_experience(1, 0.35, &mut random))
|
||||
.sum();
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
}
|
||||
@@ -1610,6 +1610,14 @@ impl Recipes {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map_or(0.0, |c| c.experience)
|
||||
}
|
||||
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
let index = self.cook_index.get_or_init(|| {
|
||||
let mut index = CookIndex::new();
|
||||
|
||||
@@ -71,10 +71,52 @@ impl Names {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
/// An item's display name in the selected language: `item.*` then
|
||||
/// `block.*`, then the pinned English map (`Names::item`).
|
||||
pub fn display_item(&self, id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
for prefix in ["item", "block"] {
|
||||
if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
|
||||
return name.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.item(id)
|
||||
}
|
||||
/// A translation key's text in the selected language, else the pinned
|
||||
/// English map (`HoverName` and tooltip keys such as potion names).
|
||||
pub fn display_get(&self, key: &str) -> Option<String> {
|
||||
crate::lang::current()
|
||||
.and_then(|lang| lang.get(key).map(str::to_owned))
|
||||
.or_else(|| self.get(key).map(str::to_owned))
|
||||
}
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
|
||||
/// export's `title` is the English fallback.
|
||||
pub fn item_group_key(id: &str) -> Option<&'static str> {
|
||||
Some(match id.trim_start_matches("minecraft:") {
|
||||
"building_blocks" => "itemGroup.buildingBlocks",
|
||||
"colored_blocks" => "itemGroup.coloredBlocks",
|
||||
"natural_blocks" => "itemGroup.natural",
|
||||
"functional_blocks" => "itemGroup.functional",
|
||||
"redstone_blocks" => "itemGroup.redstone",
|
||||
"hotbar" => "itemGroup.hotbar",
|
||||
"search" => "itemGroup.search",
|
||||
"tools_and_utilities" => "itemGroup.tools",
|
||||
"combat" => "itemGroup.combat",
|
||||
"food_and_drinks" => "itemGroup.foodAndDrink",
|
||||
"ingredients" => "itemGroup.ingredients",
|
||||
"spawn_eggs" => "itemGroup.spawnEggs",
|
||||
"op_blocks" => "itemGroup.op",
|
||||
"inventory" => "itemGroup.inventory",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
impl Creative {
|
||||
pub const WIDTH: f32 = 195.0;
|
||||
pub const HEIGHT: f32 = 136.0;
|
||||
@@ -303,7 +345,17 @@ impl Creative {
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -314,11 +366,15 @@ impl Creative {
|
||||
&e.lines
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => any(lines.iter().map(String::as_str), &text),
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -759,6 +815,24 @@ mod tests {
|
||||
assert_eq!(q("#craft"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_the_display_name_in_the_selected_language() {
|
||||
let c = creative(0);
|
||||
let names = Names {
|
||||
map: HashMap::from([
|
||||
("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
@@ -794,6 +868,20 @@ mod tests {
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_tab_titles_have_vanilla_keys() {
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:building_blocks"),
|
||||
Some("itemGroup.buildingBlocks")
|
||||
);
|
||||
assert_eq!(
|
||||
item_group_key("minecraft:food_and_drinks"),
|
||||
Some("itemGroup.foodAndDrink")
|
||||
);
|
||||
assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
|
||||
assert_eq!(item_group_key("minecraft:nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_moves_one_row_per_notch() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -891,8 +891,11 @@ impl Font {
|
||||
}
|
||||
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
|
||||
}
|
||||
/// Font.width of a component: `§` codes in the spans are formatting,
|
||||
/// not glyphs.
|
||||
pub fn width_text(&self, t: &Text) -> f32 {
|
||||
t.chars()
|
||||
t.resolved()
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
|
||||
@@ -20,6 +20,7 @@ mod bossbars;
|
||||
mod breezes;
|
||||
mod cartography;
|
||||
mod chat;
|
||||
mod client_ambient;
|
||||
mod clipboard;
|
||||
mod combat;
|
||||
mod command_blocks;
|
||||
@@ -540,6 +541,8 @@ struct GameLevel<'w> {
|
||||
seed: i64,
|
||||
behaviours: &'w Behaviours,
|
||||
items: &'w Items,
|
||||
/// Cooking recipes' experience, which a removed furnace pays out.
|
||||
recipes: &'w horizon_client::crafting::Recipes,
|
||||
/// Container loot tables (`unpackLootTable`).
|
||||
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
@@ -1573,6 +1576,8 @@ struct Game<'a> {
|
||||
cmds: commands::extra::CommandExtras,
|
||||
ui: chat::UiState,
|
||||
combat: combat::CombatTracker,
|
||||
/// Each guest's own `CombatTracker`, out of `combat` between turns.
|
||||
guest_combat: std::collections::HashMap<u32, combat::CombatTracker>,
|
||||
mount: mounts::MountState,
|
||||
stats: stats::Stats,
|
||||
score: scoreboard::ScoreState,
|
||||
@@ -2434,6 +2439,7 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
behaviours: self.res.behaviours(),
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
|
||||
registry: self.pack.assets.registry,
|
||||
rules,
|
||||
@@ -4476,6 +4482,7 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims()
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
self.tick_client_ambient();
|
||||
}
|
||||
// ServerBossEvent broadcasts, after this level's entities have ticked.
|
||||
self.publish_boss_bars();
|
||||
@@ -5334,22 +5341,32 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {}
|
||||
Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy),
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let (tex, title) = match f.kind {
|
||||
let (tex, key, fallback) = match f.kind {
|
||||
horizon_client::containers::FurnaceKind::Furnace => {
|
||||
("gui/container/furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => {
|
||||
("gui/container/blast_furnace", "Blast Furnace")
|
||||
("gui/container/furnace", "container.furnace", "Furnace")
|
||||
}
|
||||
horizon_client::containers::FurnaceKind::BlastFurnace => (
|
||||
"gui/container/blast_furnace",
|
||||
"container.blast_furnace",
|
||||
"Blast Furnace",
|
||||
),
|
||||
horizon_client::containers::FurnaceKind::Smoker => {
|
||||
("gui/container/smoker", "Smoker")
|
||||
("gui/container/smoker", "container.smoker", "Smoker")
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let title = &self.container_title(title.to_owned());
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, ox + (176.0 - tw) / 2.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
oy + 6.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
if f.lit_remaining > 0 {
|
||||
// AbstractFurnaceScreen: flame height ceil(lit * 13) + 1.
|
||||
let total = if f.lit_total == 0 { 200 } else { f.lit_total };
|
||||
@@ -5403,14 +5420,12 @@ impl Game<'_> {
|
||||
"gui/container/inventory"
|
||||
};
|
||||
part(batch, texture, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let (title, tx) = if self.craft_size == 3 {
|
||||
("Crafting", 29.0)
|
||||
} else {
|
||||
("Crafting", 97.0)
|
||||
};
|
||||
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
|
||||
let title = self.translate("container.crafting", "Crafting");
|
||||
let tx = if self.craft_size == 3 { 29.0 } else { 97.0 };
|
||||
batch.text(atlas, &title, ox + tx, oy + 6.0, grey, false);
|
||||
if self.craft_size == 3 {
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5967,6 +5982,12 @@ impl Game<'_> {
|
||||
// Attached after the slots were set: setting them is not a click.
|
||||
menu.merchant = session.map(|(s, _)| s);
|
||||
}
|
||||
// `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot.
|
||||
let furnace_before = if matches!(kind, Kind::Furnace(_)) {
|
||||
menu.slot(2).cloned().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let outcome = run(&mut menu, &env, &to_menu);
|
||||
let made = maps.made.take();
|
||||
match outcome {
|
||||
@@ -5984,6 +6005,12 @@ impl Game<'_> {
|
||||
self.merchant_sync_out(s);
|
||||
}
|
||||
let after: Vec<Option<ItemStack>> = (0..total).map(|i| menu.slot(i).cloned()).collect();
|
||||
// Only a furnace has a result slot to compare: an enchanting table has two slots.
|
||||
let furnace_after = if matches!(kind, Kind::Furnace(_)) {
|
||||
after[2].as_ref().map_or(0, |s| s.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let carried = menu.carried.take();
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let sounds = std::mem::take(&mut menu.sounds);
|
||||
@@ -6008,6 +6035,13 @@ impl Game<'_> {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.award_crafted(&crafted);
|
||||
if furnace_before > furnace_after
|
||||
&& self.remote.is_none()
|
||||
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -8803,6 +8837,27 @@ impl Game<'_> {
|
||||
self.give_orb_points(gained);
|
||||
}
|
||||
}
|
||||
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
|
||||
/// furnace at `pos`, which pops the experience of what it made at them
|
||||
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
|
||||
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
|
||||
let used = match self.world.block_entities.get_mut(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let used = f.recipe_experience(self.res.recipes());
|
||||
f.recipes_used.clear();
|
||||
used
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (count, experience) in used {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.spawn_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
@@ -9446,6 +9501,11 @@ impl Game<'_> {
|
||||
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
|
||||
batch.centered_origin(Creative::WIDTH, Creative::HEIGHT)
|
||||
}
|
||||
/// A creative tab's title: `itemGroup.*`, else the export's English title.
|
||||
fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
|
||||
horizon_client::creative::item_group_key(&tab.id)
|
||||
.map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title))
|
||||
}
|
||||
/// Vanilla tooltip: dark box with a purple gradient border.
|
||||
fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -9515,9 +9575,10 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(&tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&tab.title,
|
||||
&title,
|
||||
ox + 8.0,
|
||||
oy + 6.0,
|
||||
rgba(64, 64, 64, 255),
|
||||
@@ -9628,7 +9689,12 @@ impl Game<'_> {
|
||||
&& self.cursor.1 >= oy + ty
|
||||
&& self.cursor.1 < oy + ty + 32.0
|
||||
{
|
||||
self.tooltip(batch, &t.title, self.cursor.0, self.cursor.1);
|
||||
self.tooltip(
|
||||
batch,
|
||||
&self.creative_tab_title(t),
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.draw_carried(batch);
|
||||
@@ -12049,6 +12115,7 @@ impl<'a> Game<'a> {
|
||||
cmds: Default::default(),
|
||||
ui: Default::default(),
|
||||
combat: Default::default(),
|
||||
guest_combat: Default::default(),
|
||||
mount: Default::default(),
|
||||
stats: Default::default(),
|
||||
score: Default::default(),
|
||||
|
||||
@@ -519,7 +519,12 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
|
||||
@@ -18,7 +18,7 @@ fn chat_tree<'a>(
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => tree,
|
||||
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
|
||||
None => mods.dispatcher(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! The window's own ambient effects of entities, which a client makes for
|
||||
//! itself (`level().isClientSide()` branches): endermen's and endermites'
|
||||
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{MobEvent, ambient};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_client_ambient(&mut self) {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
ambient::client_ai_step(m, &mut events);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
MobEvent::ParticleAt { kind, at, velocity } => {
|
||||
if let Some(ty) = ParticleType::from_name(kind) {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), at, velocity);
|
||||
}
|
||||
}
|
||||
// Each guest of a LAN host makes its own.
|
||||
MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
}
|
||||
}
|
||||
@@ -191,8 +191,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
self.recheck_combat();
|
||||
let me = Text::literal(PLAYER_NAME);
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
self.combat.in_combat = false;
|
||||
let Some(killing) = entries.last() else {
|
||||
|
||||
@@ -444,7 +444,7 @@ impl Game<'_> {
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| i.stack.item.to_string())
|
||||
.unwrap_or_default();
|
||||
Text::literal(self.pack.names.item(&item))
|
||||
Text::literal(self.pack.names.display_item(&item))
|
||||
}
|
||||
Target::Mob(id)
|
||||
if self
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
Result, environment::Environment, items::ItemStack, mesher::Vertex, motion::PlayerMotion,
|
||||
Result,
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -1122,6 +1127,35 @@ impl Game<'_> {
|
||||
self.end.eyes = eyes;
|
||||
}
|
||||
|
||||
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
|
||||
/// move back), four bubbles in water, else a portal particle.
|
||||
pub(super) fn eye_particles(&mut self) {
|
||||
for i in 0..self.end.eyes.len() {
|
||||
let e = &self.end.eyes[i];
|
||||
let movement = (e.position - e.previous).to_array();
|
||||
let origin = (e.position - (e.position - e.previous) * 0.25).to_array();
|
||||
let block = origin.map(|v| v.floor() as i32);
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(block[0], block[1], block[2]))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
if water {
|
||||
let bubble = ParticleOptions::simple(ParticleType::Bubble);
|
||||
for _ in 0..4 {
|
||||
self.particles.add(&bubble, origin, movement);
|
||||
}
|
||||
} else {
|
||||
let r = self.particles.random();
|
||||
let x = origin[0] + r.next_double() * 0.6 - 0.3;
|
||||
let z = origin[2] + r.next_double() * 0.6 - 0.3;
|
||||
let portal = ParticleOptions::simple(ParticleType::Portal);
|
||||
self.particles
|
||||
.add(&portal, [x, origin[1] - 0.5, z], movement);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// ThrownItemRenderer: the ender eye item facing the camera.
|
||||
fn eye_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let Some(r) = self.gui.atlas.sprites.get("item/ender_eye").copied() else {
|
||||
|
||||
@@ -25,9 +25,15 @@ pub(super) struct Rocket {
|
||||
pub(super) velocity: Vec3,
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
|
||||
/// the first block it hits.
|
||||
pub(super) angled: bool,
|
||||
@@ -48,6 +54,8 @@ impl Rocket {
|
||||
lifetime: i32::MAX,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
@@ -63,7 +71,7 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
.data()
|
||||
@@ -89,6 +97,8 @@ impl Game<'_> {
|
||||
lifetime,
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
}
|
||||
@@ -127,7 +137,8 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let rocket = self.new_rocket(at, stack, true);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -140,7 +151,18 @@ impl Game<'_> {
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
if r.attached {
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
// A guest's rocket follows them; their own client boosts them.
|
||||
let guest = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|host| host.hub.guests.iter().find(|g| g.conn == conn));
|
||||
if let Some(g) = guest {
|
||||
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
}
|
||||
} else if r.attached {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
@@ -229,8 +251,19 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
self.hurt_player_by("minecraft:fireworks", damage);
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
|
||||
@@ -162,6 +162,13 @@ fn mob_meta(
|
||||
meta.push((3, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the off
|
||||
// hand 2 (a skeleton draws its bow, a witch raises her potion).
|
||||
let using = i8::from(m.extra.using_item > 0) | i8::from(m.extra.using_off_hand) << 1;
|
||||
if using != 0 {
|
||||
meta.push((8, serializer::BYTE, Value::Byte(using)));
|
||||
}
|
||||
defaults.push((8, serializer::BYTE, Value::Byte(0)));
|
||||
// LivingEntity.DATA_HEALTH_ID: defined as 1, then set to the max health.
|
||||
let health = m.health.max(0.0);
|
||||
if health != 1.0 {
|
||||
@@ -293,6 +300,21 @@ fn mob_meta(
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose and the held
|
||||
// item's loading animation.
|
||||
"minecraft:pillager" => {
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.variant2 as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
@@ -983,6 +1005,7 @@ impl Game<'_> {
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
|
||||
@@ -144,6 +144,8 @@ pub(super) struct GuestSwap {
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
/// The guest whose turn this one nests in, if any.
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
@@ -257,6 +259,14 @@ impl Game<'_> {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
// The guest's own CombatTracker: damage to them is not the host's.
|
||||
// A new body starts with no record of the last one.
|
||||
let guest_combat = if fresh {
|
||||
Default::default()
|
||||
} else {
|
||||
self.guest_combat.remove(&conn).unwrap_or_default()
|
||||
};
|
||||
let host_combat = std::mem::replace(&mut self.combat, guest_combat);
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
motion.ground.on_ground = g.on_ground;
|
||||
@@ -265,6 +275,8 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -421,6 +433,7 @@ impl Game<'_> {
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
combat: host_combat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -472,12 +485,16 @@ impl Game<'_> {
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
// The guest's damage record back into their slot, the host's in place.
|
||||
let guest_combat = std::mem::replace(&mut self.combat, combat);
|
||||
self.guest_combat.insert(conn, guest_combat);
|
||||
play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers);
|
||||
// What the guest shot is theirs (Projectile.setOwner).
|
||||
for p in self.creatures.projectiles.iter_mut().skip(projectiles) {
|
||||
@@ -1857,31 +1874,34 @@ impl Game<'_> {
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
// the player's team hides it.
|
||||
Some(key) if self.ui.rules.bool("show_death_messages") => {
|
||||
let text = horizon_client::text::Text::translate(
|
||||
&key,
|
||||
&[self.player_text(&name, uuid)],
|
||||
);
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
message
|
||||
}
|
||||
Some(_) => literal(""),
|
||||
None => literal(&format!("{name} died")),
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
@@ -1897,6 +1917,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die`'s message from a guest's own `CombatTracker`, which
|
||||
/// sits in `guest_combat` between turns.
|
||||
fn guest_death_message(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
me: horizon_client::text::Text,
|
||||
) -> horizon_client::text::Text {
|
||||
let mut tracker = self.guest_combat.remove(&conn).unwrap_or_default();
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
let text = self.combat_death_message_for(me);
|
||||
std::mem::swap(&mut self.combat, &mut tracker);
|
||||
self.guest_combat.insert(conn, tracker);
|
||||
text
|
||||
}
|
||||
|
||||
/// `Player.tick`'s sleep counter, and `LivingEntity.tick`'s wake-up of a
|
||||
/// sleeper who died or whose bed is gone.
|
||||
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -2106,10 +2141,14 @@ impl Game<'_> {
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// Landing damage from this travel reaches the tracker too.
|
||||
events.append(&mut player.events);
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
(events, marked.then_some(player.velocity))
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
game.record_combat(&events);
|
||||
(events, velocity)
|
||||
});
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
|
||||
@@ -225,6 +225,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
_ => return false,
|
||||
};
|
||||
let furnace = matches!(kind, Kind::Furnace(_));
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
let crafter_data = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
@@ -284,6 +285,12 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// FurnaceResultSlot.checkTakeAchievements: the guest took from the output.
|
||||
let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count);
|
||||
if furnace && count(menu.slot(2)) < count(items[2].as_ref()) {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
|
||||
@@ -404,11 +404,19 @@ impl Game<'_> {
|
||||
Value::Item(hub.stack(&Some(r.item.clone()))),
|
||||
));
|
||||
}
|
||||
let boosted = match r.guest {
|
||||
Some(conn) => hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(host) = host_entity
|
||||
&& let Some(player) = boosted
|
||||
{
|
||||
e.meta
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(host + 1)));
|
||||
.push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(player + 1)));
|
||||
}
|
||||
if r.angled {
|
||||
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -218,7 +218,7 @@ impl Game<'_> {
|
||||
oy: f32,
|
||||
draws: &[ScreenDraw],
|
||||
background: bool,
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut tooltip = None;
|
||||
let mut sheet: Option<String> = None;
|
||||
@@ -261,7 +261,10 @@ impl Game<'_> {
|
||||
..
|
||||
} => self.draw_banner_result(batch, ox + *x0 as f32, oy + *y0 as f32, base, layers),
|
||||
ScreenDraw::Tooltip { key } => {
|
||||
tooltip = Some(vec![(self.translate(key, key), u32::MAX)]);
|
||||
tooltip = Some(vec![(
|
||||
horizon_client::text::Text::literal(self.translate(key, key)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
ScreenDraw::SpriteOf { id } => sheet = Some(id.clone()),
|
||||
ScreenDraw::Fill {
|
||||
@@ -314,7 +317,7 @@ impl Game<'_> {
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
local: (f32, f32),
|
||||
) -> Option<Vec<(String, u32)>> {
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let s = self.station.as_ref()?;
|
||||
let l = &s.loom;
|
||||
let dye = s.inputs[1]
|
||||
|
||||
@@ -889,13 +889,31 @@ impl Game<'_> {
|
||||
let key_is_cart = self
|
||||
.container
|
||||
.is_some_and(|k| k.1 == super::minecarts::CART_KEY_Y);
|
||||
let (texture, height, title) = match screen {
|
||||
Screen::Hopper if key_is_cart => {
|
||||
("gui/container/hopper", 133.0, "Minecart with Hopper")
|
||||
}
|
||||
Screen::Hopper => ("gui/container/hopper", 133.0, "Item Hopper"),
|
||||
Screen::Dispenser => ("gui/container/dispenser", 166.0, "Dispenser"),
|
||||
Screen::Dropper => ("gui/container/dispenser", 166.0, "Dropper"),
|
||||
let (texture, height, key, fallback) = match screen {
|
||||
Screen::Hopper if key_is_cart => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"entity.minecraft.hopper_minecart",
|
||||
"Minecart with Hopper",
|
||||
),
|
||||
Screen::Hopper => (
|
||||
"gui/container/hopper",
|
||||
133.0,
|
||||
"container.hopper",
|
||||
"Item Hopper",
|
||||
),
|
||||
Screen::Dispenser => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dispenser",
|
||||
"Dispenser",
|
||||
),
|
||||
Screen::Dropper => (
|
||||
"gui/container/dispenser",
|
||||
166.0,
|
||||
"container.dropper",
|
||||
"Dropper",
|
||||
),
|
||||
};
|
||||
if let Some(mut r) = atlas.sprites.get(texture).copied() {
|
||||
let dv = (r.v1 - r.v0) / r.h as f32;
|
||||
@@ -905,23 +923,17 @@ impl Game<'_> {
|
||||
batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX);
|
||||
}
|
||||
let grey = horizon_client::hud::rgba(64, 64, 64, 255);
|
||||
let title = &self
|
||||
let title = self
|
||||
.remote_menu_title()
|
||||
.unwrap_or_else(|| self.container_title(title.to_owned()));
|
||||
.unwrap_or_else(|| self.container_title(self.translate(key, fallback)));
|
||||
let title_x = if screen == Screen::Hopper {
|
||||
8.0
|
||||
} else {
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0
|
||||
(176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0
|
||||
};
|
||||
batch.text(atlas, title, ox + title_x, oy + 6.0, grey, false);
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
ox + 8.0,
|
||||
oy + height - 94.0,
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,8 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
self.paint_screen(batch, ox, oy, rest, true);
|
||||
}
|
||||
/// `gui.togglable_slot` over an empty, enabled grid slot.
|
||||
|
||||
@@ -259,7 +259,7 @@ impl Game<'_> {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
|
||||
let Some(item) = item else { continue };
|
||||
let left = off_hand != mob.extra.main_hand_left;
|
||||
let left = off_hand != mob.main_arm_left();
|
||||
let Some(t) =
|
||||
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
|
||||
else {
|
||||
@@ -305,7 +305,7 @@ impl Game<'_> {
|
||||
.player_model
|
||||
.as_ref()
|
||||
.is_some_and(|p| std::ptr::eq(p, mob));
|
||||
owner.main_arm_left = mob.extra.main_hand_left;
|
||||
owner.main_arm_left = mob.main_arm_left();
|
||||
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = 72_000;
|
||||
|
||||
@@ -42,6 +42,14 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// `command.run` lines for this window's own world, run on the next tick:
|
||||
/// run at once, their chat lines would reach the mods while their output
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
#[cfg(feature = "mods")]
|
||||
commands: Vec<String>,
|
||||
/// The server's command tree with the running mods' commands added.
|
||||
#[cfg(feature = "mods")]
|
||||
remote_tree: Option<Dispatcher>,
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
@@ -69,6 +77,14 @@ impl ModsState {
|
||||
}
|
||||
vanilla::game_dispatcher()
|
||||
}
|
||||
/// The server's tree plus the mods' commands, when a mod owns any.
|
||||
pub(super) fn remote_dispatcher(&self) -> Option<&Dispatcher> {
|
||||
#[cfg(feature = "mods")]
|
||||
if let Some(tree) = &self.remote_tree {
|
||||
return Some(tree);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether a mod runs: the chat keeps its lines for them.
|
||||
pub(super) fn running(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -178,6 +194,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
pub(super) fn tick_mods(&mut self) {}
|
||||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||||
@@ -267,7 +284,20 @@ impl Game<'_> {
|
||||
.peek()
|
||||
.is_some()
|
||||
.then(|| vanilla::game_dispatcher().with_mod_commands(names));
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
|
||||
/// The server's command tree with the mods' commands, for the chat.
|
||||
pub(super) fn merge_remote_tree(&mut self) {
|
||||
let names: Vec<String> = self.mods.mods.commands().map(str::to_owned).collect();
|
||||
self.mods.remote_tree = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.commands.as_ref())
|
||||
.filter(|_| !names.is_empty())
|
||||
.map(|t| t.with_mod_commands(names.iter().map(String::as_str)));
|
||||
}
|
||||
|
||||
/// After the options changed: restart the mods if the list of the ones
|
||||
/// turned on did.
|
||||
pub(super) fn sync_mods(&mut self) {
|
||||
@@ -312,6 +342,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// A game tick.
|
||||
pub(super) fn tick_mods(&mut self) {
|
||||
// Here, at the player's level: shown inside a console call (a control
|
||||
// socket command set the hook off), they would run as the console.
|
||||
for line in std::mem::take(&mut self.mods.commands) {
|
||||
self.run_command(&line);
|
||||
}
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -371,6 +406,19 @@ impl Game<'_> {
|
||||
}
|
||||
Output::Feedback { text, error: false } => Text::literal(text),
|
||||
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
|
||||
// Vanilla's commands only: a mod running a mod command could loop.
|
||||
Output::Command { line } => {
|
||||
let name = line.split(' ').next().unwrap_or_default();
|
||||
if !vanilla_command(name) {
|
||||
// Dropped.
|
||||
} else if self.remote.is_some() {
|
||||
// The server checks the player's permissions.
|
||||
self.send_chat(&format!("/{line}"));
|
||||
} else {
|
||||
self.mods.commands.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
|
||||
@@ -470,7 +470,8 @@ impl Game<'_> {
|
||||
None => String::new(),
|
||||
};
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false);
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == open.mob) {
|
||||
self.draw_mob_preview(
|
||||
batch,
|
||||
|
||||
@@ -151,10 +151,13 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.unwrap_or_else(|| Text::literal(&name))
|
||||
};
|
||||
// StringDecomposer.iterateFormatted: `§` codes a server leaves
|
||||
// inside the team prefix or the name itself apply at draw time.
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(member),
|
||||
None => member,
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
// below-name distance (10), under the name.
|
||||
@@ -162,7 +165,7 @@ impl Game<'_> {
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
@@ -213,14 +216,15 @@ impl Game<'_> {
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(name.clone()),
|
||||
None => name.clone(),
|
||||
};
|
||||
}
|
||||
.resolve_legacy();
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
|
||||
@@ -247,6 +247,7 @@ impl Game<'_> {
|
||||
pub(super) fn load_language(&mut self, code: &str) {
|
||||
let root = self.options.resources.clone();
|
||||
lang::set(lang::Language::load(&[&root], code));
|
||||
self.refresh_remote_language();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
|
||||
|
||||
@@ -203,6 +203,7 @@ impl Game<'_> {
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(root);
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
|
||||
@@ -755,7 +755,7 @@ impl Game<'_> {
|
||||
image_width,
|
||||
&self.potions.effects.list,
|
||||
self.clock.rate(),
|
||||
&|key| self.pack.names.get(key).map(str::to_owned),
|
||||
&|key| self.pack.names.display_get(key),
|
||||
&|text| self.gui.atlas.font.width(text).ceil() as i32,
|
||||
);
|
||||
if let Some(lines) = inventory_effects::paint(
|
||||
|
||||
@@ -502,7 +502,7 @@ impl Game<'_> {
|
||||
horizon_client::item_tooltip::lines(
|
||||
stack,
|
||||
self.res.items().get(&stack.item),
|
||||
&|k| self.pack.names.get(k).map(str::to_owned),
|
||||
&|k| self.pack.names.display_get(k),
|
||||
)
|
||||
})
|
||||
{
|
||||
@@ -1163,8 +1163,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Paints recipe book calls; returns the first tooltip set (vanilla keeps
|
||||
/// the first one a frame asks for).
|
||||
fn paint_book(&mut self, batch: &mut HudBatch, calls: &[Drawn]) -> Option<Vec<(String, u32)>> {
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
fn paint_book(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
calls: &[Drawn],
|
||||
) -> Option<Vec<(horizon_client::text::Text, u32)>> {
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
for d in calls {
|
||||
let start = batch.vertices.len();
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -1259,7 +1263,14 @@ impl Game<'_> {
|
||||
tooltip = Some(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.iter().map(|r| r.text.as_str()).collect(), u32::MAX))
|
||||
.map(|l| {
|
||||
(
|
||||
horizon_client::text::Text::literal(
|
||||
l.iter().map(|r| r.text.as_str()).collect::<String>(),
|
||||
),
|
||||
u32::MAX,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
@@ -1269,10 +1280,10 @@ impl Game<'_> {
|
||||
let mut lines = self.stack_tooltip(stack);
|
||||
if *more {
|
||||
lines.push((
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"gui.recipebook.moreRecipes",
|
||||
"Right Click for More",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1904,25 +1904,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS):
|
||||
// another player starts using the item in a hand (eating, a
|
||||
// bow drawn, a shield up), or stops.
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
|
||||
// too, remote_mob_data's apply reads index 8 above.
|
||||
let other = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id);
|
||||
if other && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (8, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
|
||||
// player lies down in their bed (drawn lying in it), or gets up.
|
||||
if other {
|
||||
@@ -2172,12 +2159,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::PlayerCombatKill(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let message = plain(&p.message, &lang);
|
||||
// The component resolves in the client's current language, as
|
||||
// the chat line does, not in the language the session joined
|
||||
// with (`Remote.lang` is the vanilla server-language table).
|
||||
let message = remote_text(&p.message).plain();
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
@@ -2380,6 +2365,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
// The component as sent, translated in the client's own
|
||||
@@ -3074,6 +3060,7 @@ impl Game<'_> {
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
self.tick_client_ambient();
|
||||
self.tick_client_effects();
|
||||
// Player.tick's sleep counter (the darkening and the wake-up fade).
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
@@ -3407,25 +3394,28 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// LivingEntity.updatingUsingItem for another player: the ticks the
|
||||
// item has been used before this one.
|
||||
if player && m.extra.using_item > 0 {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
using.push((
|
||||
item,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
m.extra.off_hand.clone()
|
||||
} else {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
using.push((
|
||||
item,
|
||||
m.extra.using_item as u32 - 1,
|
||||
at,
|
||||
eye,
|
||||
m.head_pitch,
|
||||
m.yaw,
|
||||
));
|
||||
}
|
||||
}
|
||||
m.extra.using_item += 1;
|
||||
}
|
||||
@@ -4822,6 +4812,7 @@ impl Game<'_> {
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: std::collections::HashMap::new(),
|
||||
}))
|
||||
}
|
||||
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
|
||||
@@ -4906,6 +4897,15 @@ impl Game<'_> {
|
||||
pub(super) fn remote_menu_title(&self) -> Option<String> {
|
||||
self.remote.as_ref()?.menu_title.clone()
|
||||
}
|
||||
/// A language change reaches the server-sent components too: their titles
|
||||
/// are resolved when the packet arrives, with the session's language.
|
||||
pub(super) fn refresh_remote_language(&mut self) {
|
||||
if let Some(lang) = horizon_client::lang::current()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.lang = Arc::new(lang.map().clone());
|
||||
}
|
||||
}
|
||||
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
|
||||
pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
|
||||
@@ -20,6 +20,17 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
// off hand 2. The flag starts and stops the use; the client ticks
|
||||
// the count itself (a bow's pull follows it).
|
||||
(8, Value::Byte(flags)) => {
|
||||
m.extra.using_off_hand = flags & 2 != 0;
|
||||
if flags & 1 == 0 {
|
||||
m.extra.using_item = 0;
|
||||
} else if m.extra.using_item == 0 {
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
@@ -53,6 +64,13 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
|
||||
m.extra.variant2 = (*spell).max(0) as u8;
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
@@ -239,4 +257,42 @@ mod tests {
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mob_flags_drive_the_aggressive_poses() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(2 | 4))]);
|
||||
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
|
||||
assert!(!m.ai.aggressive && !m.extra.left_handed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_living_flags_start_and_stop_a_mob_using_an_item() {
|
||||
let Some(mut m) = mob("minecraft:skeleton") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(1))]);
|
||||
assert_eq!(m.extra.using_item, 1);
|
||||
assert!(!m.extra.using_off_hand);
|
||||
// Using in the off hand.
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(3))]);
|
||||
assert!(m.extra.using_off_hand);
|
||||
apply(&mut m, &[(8, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.extra.using_item, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pillager_charges_and_a_spellcaster_casts() {
|
||||
let Some(mut p) = mob("minecraft:pillager") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut p, &[(17, s::BOOLEAN, Value::Bool(true))]);
|
||||
assert!(p.extra.flag);
|
||||
let mut e = mob("minecraft:evoker").unwrap();
|
||||
apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]);
|
||||
assert_eq!(e.extra.variant2, 2);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -125,6 +125,37 @@ impl Game<'_> {
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.rockets.iter().any(|r| r.id == id) {
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
// FireworkRocketEntity.DATA_ID_FIREWORKS_ITEM: its
|
||||
// explosions for `createFireworks`.
|
||||
(8, Value::Item(stack)) => {
|
||||
if let Some(stack) = self.to_stack(stack)
|
||||
&& let Some(r) = self.rockets.iter_mut().find(|r| r.id == id)
|
||||
{
|
||||
let (_, explosions) = fireworks_of(&stack);
|
||||
r.explosions = explosions;
|
||||
r.item = stack;
|
||||
}
|
||||
}
|
||||
// DATA_ATTACHED_TO_TARGET (OptionalInt: the id plus 1).
|
||||
(9, Value::Id(v)) => {
|
||||
let v = (*v > 0).then(|| (*v - 1) as u32);
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.attached_to = v;
|
||||
}
|
||||
}
|
||||
// DATA_SHOT_AT_ANGLE.
|
||||
(10, Value::Bool(a)) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.angled = *a;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -213,9 +244,30 @@ impl Game<'_> {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to the gliding local
|
||||
// player boosts it client-side too (attachedToEntity.isFallFlying).
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
for r in &mut self.rockets {
|
||||
let id = r.id;
|
||||
r.remote_step(|at| step(id, at));
|
||||
if me.is_some()
|
||||
&& r.attached_to == me
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
&& p.fall_flying
|
||||
{
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
}
|
||||
for c in &mut self.crystals {
|
||||
c.time += 1;
|
||||
|
||||
@@ -248,13 +248,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(16, 16, 16, 160));
|
||||
let title = if sign.hanging {
|
||||
"Edit Hanging Sign Message"
|
||||
let (key, fallback) = if sign.hanging {
|
||||
("hanging_sign.edit", "Edit Hanging Sign Message")
|
||||
} else {
|
||||
"Edit Sign Message"
|
||||
("sign.edit", "Edit Sign Message")
|
||||
};
|
||||
let tw = HudBatch::text_width(atlas, title);
|
||||
batch.text(atlas, title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let title = self.translate(key, fallback);
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -373,12 +373,29 @@ impl GameLevel<'_> {
|
||||
let mut neighbour_updates = 0;
|
||||
if side_effects {
|
||||
match kind {
|
||||
PreRemove::Contents { slots, .. } => {
|
||||
PreRemove::Contents { slots, furnace } => {
|
||||
let items = entity.as_ref().map_or(&[][..], BlockEntity::slots);
|
||||
for slot in 0..slots {
|
||||
let stack = items.get(slot).cloned().flatten();
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
// AbstractFurnaceBlockEntity.preRemoveSideEffects: then the
|
||||
// experience of what it made, at the block's centre.
|
||||
if furnace && let Some(BlockEntity::Furnace(f)) = &entity {
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
for (count, experience) in f.recipe_experience(self.recipes) {
|
||||
let xp = horizon_client::containers::create_experience(
|
||||
count,
|
||||
experience,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.award_orbs(at, xp);
|
||||
}
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
|
||||
@@ -277,15 +277,10 @@ fn argb(c: i32) -> u32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn data(&self) -> &stations::StationData {
|
||||
self.res.stations()
|
||||
}
|
||||
/// `Component.translatable`: the selected language (which `lang` layers
|
||||
/// over `en_us`), the caller's English text last.
|
||||
pub(super) fn translate(&self, key: &str, fallback: &str) -> String {
|
||||
self.data()
|
||||
.enchantments
|
||||
.translate(key)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
horizon_client::lang::get_or(key, fallback)
|
||||
}
|
||||
fn brewing_open(&self) -> bool {
|
||||
matches!(self.container_kind(), Some(BlockEntity::Brewing(_)))
|
||||
@@ -421,7 +416,8 @@ impl Game<'_> {
|
||||
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
|
||||
match &s.inputs[0] {
|
||||
Some(input)
|
||||
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
|
||||
if input.custom_name().is_none()
|
||||
&& s.name == self.pack.names.display_item(&input.item) =>
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
@@ -462,7 +458,7 @@ impl Game<'_> {
|
||||
let name = self.anvil_item_name(&s);
|
||||
let hover = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
s.anvil = stations::anvil_result(
|
||||
&data.enchantments,
|
||||
@@ -895,7 +891,7 @@ impl Game<'_> {
|
||||
s.anvil_last = s.inputs[0].clone();
|
||||
s.name = s.inputs[0]
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.item(id), i))
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.station_changed();
|
||||
@@ -1096,7 +1092,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(String, u32)>> = None;
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
@@ -1345,16 +1341,19 @@ impl Game<'_> {
|
||||
let clue = self
|
||||
.translate("container.enchant.clue", "%s . . . ?")
|
||||
.replace("%s", &name);
|
||||
let mut lines = vec![(clue, u32::MAX)];
|
||||
let mut lines =
|
||||
vec![(horizon_client::text::Text::literal(clue), u32::MAX)];
|
||||
if !self.creative {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
lines.push((horizon_client::text::Text::literal(""), u32::MAX));
|
||||
if level < cost {
|
||||
lines.push((
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
horizon_client::text::Text::literal(
|
||||
self.translate(
|
||||
"container.enchant.level.requirement",
|
||||
"Level Requirement: %s",
|
||||
)
|
||||
.replace("%s", &cost.to_string()),
|
||||
),
|
||||
rgba(255, 85, 85, 255),
|
||||
));
|
||||
} else {
|
||||
@@ -1372,7 +1371,7 @@ impl Game<'_> {
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((
|
||||
lapis,
|
||||
horizon_client::text::Text::literal(lapis),
|
||||
if gold >= n as i32 {
|
||||
rgba(170, 170, 170, 255)
|
||||
} else {
|
||||
@@ -1391,7 +1390,10 @@ impl Game<'_> {
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
};
|
||||
lines.push((levels, rgba(170, 170, 170, 255)));
|
||||
lines.push((
|
||||
horizon_client::text::Text::literal(levels),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
tooltip = Some(lines);
|
||||
@@ -1500,10 +1502,10 @@ impl Game<'_> {
|
||||
);
|
||||
if inside(local, 65.0, 46.0, 28.0, 21.0) {
|
||||
tooltip = Some(vec![(
|
||||
self.translate(
|
||||
horizon_client::text::Text::literal(self.translate(
|
||||
"container.upgrade.error_tooltip",
|
||||
"Item can't be upgraded this way",
|
||||
),
|
||||
)),
|
||||
u32::MAX,
|
||||
)]);
|
||||
}
|
||||
@@ -1630,9 +1632,12 @@ impl Game<'_> {
|
||||
|
||||
// ---------------------------------------------------------------- tooltips
|
||||
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, potion
|
||||
/// effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
|
||||
/// `ItemStack.getTooltipLines` subset: name in rarity colour, lore,
|
||||
/// potion effects, enchantments (gray, curses red), stored enchantments, trims.
|
||||
pub(super) fn stack_tooltip(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let props = e.props(&stack.item);
|
||||
let rarity = horizon_client::item_tooltip::rarity(
|
||||
@@ -1648,39 +1653,43 @@ impl Game<'_> {
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
e.translate("block.minecraft.ominous_banner")
|
||||
.map_or_else(|| "Ominous Banner".to_owned(), str::to_owned)
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
|| stack.item.ends_with("potion")
|
||||
|| &*stack.item == "minecraft:tipped_arrow"
|
||||
{
|
||||
let key = brewing::potion_name_key(&stack.item, stack.potion());
|
||||
e.translate(&key)
|
||||
.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned)
|
||||
self.pack
|
||||
.names
|
||||
.display_get(&key)
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
|
||||
} else {
|
||||
self.pack.names.item(&stack.item)
|
||||
self.pack.names.display_item(&stack.item)
|
||||
};
|
||||
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
|
||||
let mut lines = Vec::new();
|
||||
if display.name_visible(self.screen == Screen::Creative) {
|
||||
lines.push((name, color));
|
||||
lines.push((horizon_client::text::Text::legacy(&name), color));
|
||||
}
|
||||
let translate = |k: &str| e.translate(k).map(str::to_owned);
|
||||
let translate = |k: &str| self.pack.names.display_get(k);
|
||||
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
|
||||
lines.push((horizon_client::text::Text::legacy(&s), c));
|
||||
};
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.item(&s.item), str::to_owned)
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for line in
|
||||
for text in
|
||||
horizon_client::item_tooltip::container_lines(stack, &hover_name, &translate)
|
||||
{
|
||||
lines.push((line, u32::MAX));
|
||||
push(&mut lines, text, u32::MAX);
|
||||
}
|
||||
}
|
||||
// Fireworks / FireworkExplosion.addToTooltip.
|
||||
for line in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
lines.push((line, rgba(170, 170, 170, 255)));
|
||||
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
push(&mut lines, text, rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:potion_contents")
|
||||
&& (stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow")
|
||||
@@ -1690,7 +1699,7 @@ impl Game<'_> {
|
||||
"minecraft:tipped_arrow" => 0.125,
|
||||
_ => 1.0,
|
||||
};
|
||||
for (line, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| {
|
||||
e.translate(k).map(str::to_owned)
|
||||
}) {
|
||||
let c = match cat {
|
||||
@@ -1698,18 +1707,19 @@ impl Game<'_> {
|
||||
brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
brewing::Category::Neutral => rgba(170, 170, 170, 255),
|
||||
};
|
||||
lines.push((line, c));
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:trim")
|
||||
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
e.translate("item.minecraft.smithing_template.upgrade")
|
||||
.unwrap_or("Upgrade:")
|
||||
.to_owned(),
|
||||
rgba(170, 170, 170, 255),
|
||||
));
|
||||
);
|
||||
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
|
||||
let p = e
|
||||
.translate(&format!("trim_pattern.minecraft.{}", short(&pattern)))
|
||||
@@ -1717,38 +1727,51 @@ impl Game<'_> {
|
||||
let m = e
|
||||
.translate(&format!("trim_material.minecraft.{}", short(&material)))
|
||||
.map_or_else(|| short(&material), str::to_owned);
|
||||
lines.push((format!(" {p}"), rgba(170, 170, 170, 255)));
|
||||
lines.push((format!(" {m}"), rgba(170, 170, 170, 255)));
|
||||
push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255));
|
||||
push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255));
|
||||
}
|
||||
if display.shows("minecraft:stored_enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.stored_enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:enchantments") {
|
||||
for (text, curse) in e.tooltip(stack.enchantments()) {
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
text,
|
||||
if curse {
|
||||
rgba(255, 85, 85, 255)
|
||||
} else {
|
||||
rgba(170, 170, 170, 255)
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
if display.shows("minecraft:banner_patterns") {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations().banners.layer_key(layer);
|
||||
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(&key, &key),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemLore.addToTooltip: after the enchantments, before the attributes
|
||||
// (ItemStack.addDetailsToTooltip).
|
||||
if display.shows("minecraft:lore") {
|
||||
for text in horizon_client::item_tooltip::lore_texts(stack) {
|
||||
lines.push((text, u32::MAX));
|
||||
}
|
||||
}
|
||||
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
|
||||
@@ -1762,15 +1785,15 @@ impl Game<'_> {
|
||||
match block_entity_item::spawner_entity(stack) {
|
||||
Some(id) => {
|
||||
let key = format!("entity.{}", id.replacen(':', ".", 1));
|
||||
lines.push((self.translate(&key, &id), gray));
|
||||
push(&mut lines, self.translate(&key, &id), gray);
|
||||
}
|
||||
None => {
|
||||
lines.push((String::new(), u32::MAX));
|
||||
push(&mut lines, String::new(), u32::MAX);
|
||||
let desc1 =
|
||||
self.translate("block.minecraft.spawner.desc1", "Interact with Spawn Egg:");
|
||||
lines.push((desc1, gray));
|
||||
push(&mut lines, desc1, gray);
|
||||
let desc2 = self.translate("block.minecraft.spawner.desc2", "Sets Mob Type");
|
||||
lines.push((format!(" {desc2}"), rgba(85, 85, 255, 255)));
|
||||
push(&mut lines, format!(" {desc2}"), rgba(85, 85, 255, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1798,11 +1821,13 @@ impl Game<'_> {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
];
|
||||
lines.extend(horizon_client::item_tooltip::attribute_lines(
|
||||
for (text, c) in horizon_client::item_tooltip::attribute_lines(
|
||||
&info.modifiers,
|
||||
Some(player_base),
|
||||
&translate,
|
||||
));
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:damage")
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
@@ -1810,14 +1835,15 @@ impl Game<'_> {
|
||||
&& stack.damage > 0
|
||||
&& self.options_advanced_tooltips()
|
||||
{
|
||||
lines.push((
|
||||
push(
|
||||
&mut lines,
|
||||
format!(
|
||||
"Durability: {} / {}",
|
||||
info.max_damage - stack.damage,
|
||||
info.max_damage
|
||||
),
|
||||
u32::MAX,
|
||||
));
|
||||
);
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
@@ -1832,7 +1858,11 @@ impl Game<'_> {
|
||||
"Do not use unless you know the exact contents!",
|
||||
),
|
||||
] {
|
||||
lines.push((self.translate(key, fallback), rgba(255, 85, 85, 255)));
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(key, fallback),
|
||||
rgba(255, 85, 85, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -1863,20 +1893,27 @@ impl Game<'_> {
|
||||
self.settings.file.bool("advancedItemTooltips")
|
||||
}
|
||||
/// `TooltipRenderUtil`: dark box with the purple border, first line spaced.
|
||||
pub(super) fn draw_tooltip_lines(
|
||||
pub(super) fn draw_tooltip_lines<T: Clone + Into<horizon_client::text::Text>>(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
lines: &[(String, u32)],
|
||||
lines: &[(T, u32)],
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<_> = lines
|
||||
.iter()
|
||||
.map(|(text, color)| {
|
||||
let text: horizon_client::text::Text = text.clone().into();
|
||||
(text.resolve_legacy(), *color)
|
||||
})
|
||||
.collect();
|
||||
let atlas = &self.gui.atlas;
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(t, _)| HudBatch::text_width(atlas, t))
|
||||
.map(|(t, _)| atlas.font.width_text(t))
|
||||
.fold(0.0, f32::max);
|
||||
let h = if lines.len() == 1 {
|
||||
8.0
|
||||
@@ -1929,7 +1966,7 @@ impl Game<'_> {
|
||||
);
|
||||
let mut ty = y;
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
batch.text(atlas, text, x, ty, *color, true);
|
||||
batch.text_styled(atlas, text, x, ty, *color, true);
|
||||
ty += if i == 0 { 12.0 } else { 10.0 };
|
||||
}
|
||||
}
|
||||
@@ -1946,7 +1983,12 @@ impl Game<'_> {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(text) = self.crafter_tooltip(slot) {
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,8 @@ impl Game<'_> {
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
batch.text(atlas, "Inventory", ox + 8.0, oy + 73.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
}
|
||||
let rows = self.chest_rows().unwrap_or(3);
|
||||
@@ -301,9 +302,10 @@ impl Game<'_> {
|
||||
grey,
|
||||
false,
|
||||
);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(
|
||||
atlas,
|
||||
"Inventory",
|
||||
&inventory,
|
||||
ox + 8.0,
|
||||
oy + 114.0 + rows as f32 * 18.0 - 94.0,
|
||||
grey,
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Game<'_> {
|
||||
Some(HeldName {
|
||||
item: stack.item.to_string(),
|
||||
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
|
||||
self.pack.names.get(key).map(str::to_owned)
|
||||
self.pack.names.display_get(key)
|
||||
})),
|
||||
rarity_color: match rarity {
|
||||
"uncommon" => text::YELLOW,
|
||||
|
||||
@@ -399,7 +399,7 @@ impl Game<'_> {
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let names = |id: &str| self.pack.names.item(id);
|
||||
let names = |id: &str| self.pack.names.display_item(id);
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&names, s))
|
||||
|
||||
@@ -92,6 +92,8 @@ impl Game<'_> {
|
||||
match who {
|
||||
Some(Who::Player) => {
|
||||
if !self.creative {
|
||||
// WitherSkull.onHitEntity's source: the wither launched it.
|
||||
self.combat.projectile_owner = owner;
|
||||
self.mob_hits_player_kind(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
|
||||
@@ -2182,15 +2182,26 @@ impl Renderer {
|
||||
)
|
||||
});
|
||||
let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some());
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
|
||||
&& adapter
|
||||
.get_downlevel_capabilities()
|
||||
.flags
|
||||
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
|
||||
// Without indirect draws (OpenGL before 4.3 without compute shaders)
|
||||
// even creating an indirect buffer fails; one draw per section reads
|
||||
// `draws` on the CPU instead.
|
||||
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section draws"),
|
||||
size: 1024 * 20,
|
||||
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
|
||||
usage: if multi_draw {
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST
|
||||
} else {
|
||||
wgpu::BufferUsages::COPY_DST
|
||||
},
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let multi_draw = device
|
||||
.features()
|
||||
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
|
||||
// HORIZON_GPU_CULL=0 turns occlusion culling off, `all` culls every
|
||||
// frame however few its draws and little it hides, `shader` tests in
|
||||
// the shader as on Metal (A/B checks).
|
||||
|
||||
@@ -385,14 +385,18 @@ impl Graphics {
|
||||
if self.workers > 0 {
|
||||
return self.workers as usize;
|
||||
}
|
||||
// The pool never takes every core. The render thread and the
|
||||
// integrated server thread (single player runs on its own server
|
||||
// thread by default) each need one, and a pool that fills every core
|
||||
// starves them: the frame rate collapses to single digits while the
|
||||
// workers saturate the CPU and its memory bandwidth.
|
||||
// Vanilla's count (Util.maxAllowedExecutorThreads: every core but
|
||||
// one). The pool never takes every core: a pool that fills them all
|
||||
// starves the render and server threads, and the frame rate collapses
|
||||
// while the workers saturate the CPU and its memory bandwidth. One
|
||||
// core left is enough with the workers behind those two threads
|
||||
// (horizon_threads: niced on Linux; on macOS and Windows the server
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(2)
|
||||
.saturating_sub(1)
|
||||
.max(1);
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
@@ -555,11 +559,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
|
||||
fn automatic_workers_leave_a_core_as_vanilla_does() {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(2).max(1));
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -903,6 +903,9 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
let text = text.resolved();
|
||||
let font = &atlas.font;
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
|
||||
@@ -87,6 +87,14 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("pages", Tag::List(b.pages.clone())),
|
||||
("resolved", Tag::Byte(i8::from(b.resolved))),
|
||||
]),
|
||||
Component::Fireworks { flight, explosions } => Tag::compound([
|
||||
("flight_duration", Tag::Byte(*flight as i8)),
|
||||
(
|
||||
"explosions",
|
||||
Tag::List(explosions.iter().map(fireworks_tag).collect()),
|
||||
),
|
||||
]),
|
||||
Component::FireworkExplosion(e) => fireworks_tag(e),
|
||||
Component::Enchantments(list) => {
|
||||
// Enchantments are a synced (data-driven) registry: the
|
||||
// ids index the server's RegistryData, not the builtin
|
||||
@@ -178,6 +186,29 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// A wire `FireworkExplosion` as vanilla's NBT codec writes it
|
||||
/// (`FireworkExplosion.Shape`'s BY_ID order).
|
||||
fn fireworks_tag(e: &horizon_protocol::item::FireworkExplosion) -> Tag {
|
||||
const SHAPES: [&str; 5] = ["small_ball", "large_ball", "star", "creeper", "burst"];
|
||||
let ints = |c: &[i32]| Tag::List(c.iter().map(|c| Tag::Int(*c)).collect());
|
||||
Tag::compound([
|
||||
(
|
||||
"shape",
|
||||
Tag::String(
|
||||
SHAPES
|
||||
.get(e.shape.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or("small_ball")
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
("colors", ints(&e.colors)),
|
||||
("fade_colors", ints(&e.fade)),
|
||||
("has_trail", Tag::Byte(i8::from(e.trail))),
|
||||
("has_twinkle", Tag::Byte(i8::from(e.twinkle))),
|
||||
])
|
||||
}
|
||||
|
||||
/// A `minecraft:profile` component as the item data JSON the model reads.
|
||||
fn profile_tag(p: &Profile) -> Tag {
|
||||
let properties = p
|
||||
|
||||
@@ -487,23 +487,31 @@ pub fn lines(
|
||||
) -> Vec<String> {
|
||||
let mut lines = vec![name_line(stack, translate)];
|
||||
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
|
||||
lines.extend(lore_lines(stack));
|
||||
lines.extend(lore_texts(stack).into_iter().map(|t| t.plain()));
|
||||
}
|
||||
lines.extend(detail_lines(stack, info, translate));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `LoreComponent.addToTooltip`: the saved component lines, as plain text.
|
||||
fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
|
||||
/// the component's own styles (including explicit `false`) winning on children.
|
||||
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = crate::text::Style {
|
||||
color: Some(crate::text::DARK_PURPLE),
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let json = crate::scoreboard::tag_to_json(entry);
|
||||
let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s));
|
||||
text.plain()
|
||||
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
|
||||
for span in &mut text.spans {
|
||||
span.style.inherit(&base);
|
||||
}
|
||||
text
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -512,6 +520,42 @@ fn lore_lines(stack: &ItemStack) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lore_keeps_component_colours_under_dark_purple_italic() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:lore",
|
||||
Some(Tag::List(vec![
|
||||
Tag::compound([
|
||||
("text", Tag::String("Gold line".into())),
|
||||
("color", Tag::String("gold".into())),
|
||||
("italic", Tag::Byte(0)),
|
||||
]),
|
||||
Tag::String("Plain lore".into()),
|
||||
])),
|
||||
);
|
||||
let lines = lore_texts(&stack);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].plain(), "Gold line");
|
||||
assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD));
|
||||
assert!(!lines[0].spans[0].style.is_italic());
|
||||
assert_eq!(lines[1].plain(), "Plain lore");
|
||||
assert_eq!(
|
||||
lines[1].spans[0].style.color,
|
||||
Some(crate::text::DARK_PURPLE)
|
||||
);
|
||||
assert!(lines[1].spans[0].style.is_italic());
|
||||
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:tooltip_display",
|
||||
Some(Tag::compound([(
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String("minecraft:lore".into())]),
|
||||
)])),
|
||||
);
|
||||
assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_display_controls_name_and_component_visibility() {
|
||||
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
|
||||
@@ -445,6 +445,43 @@ impl Guest {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FireworkExplosion.STREAM_CODEC`'s fields (`Shape` by its BY_ID order).
|
||||
fn network_explosion(
|
||||
e: &crate::particles::FireworkExplosion,
|
||||
) -> horizon_protocol::item::FireworkExplosion {
|
||||
horizon_protocol::item::FireworkExplosion {
|
||||
shape: e.shape as i32,
|
||||
colors: e.colors.iter().map(|c| *c as i32).collect(),
|
||||
fade: e.fade.iter().map(|c| *c as i32).collect(),
|
||||
trail: e.trail,
|
||||
twinkle: e.twinkle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rocket's `minecraft:fireworks` and a star's `minecraft:firework_explosion`.
|
||||
pub fn firework_components(
|
||||
d: &crate::item_data::StackData,
|
||||
) -> Vec<(i32, horizon_protocol::item::Component)> {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
let mut out = Vec::new();
|
||||
if let Some((flight, explosions)) = &d.fireworks {
|
||||
out.push((
|
||||
component::FIREWORKS,
|
||||
Component::Fireworks {
|
||||
flight: i32::from(*flight),
|
||||
explosions: explosions.iter().map(network_explosion).collect(),
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(e) = &d.firework_explosion {
|
||||
out.push((
|
||||
component::FIREWORK_EXPLOSION,
|
||||
Component::FireworkExplosion(network_explosion(e)),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -535,6 +572,7 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// PotionContents: the potion by its `minecraft:potion` id.
|
||||
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
|
||||
synced_id(
|
||||
|
||||
@@ -138,6 +138,11 @@ impl Language {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.map.get(key).map(String::as_str)
|
||||
}
|
||||
/// The resolved map, for `horizon_protocol::text::plain`: a server's
|
||||
/// components carry keys, and the network layer resolves them with this.
|
||||
pub fn map(&self) -> &HashMap<String, String> {
|
||||
&self.map
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
@@ -1001,7 +1001,7 @@ impl Menu {
|
||||
fn anvil_result(&mut self, env: &Env) {
|
||||
let hover = self.containers[MAIN][0]
|
||||
.as_ref()
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.item(id), i))
|
||||
.map(|i| crate::stations::hover_name(&|id| env.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
let out = crate::stations::anvil_result(
|
||||
env.enchantments,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! behaviour, models, animations and sounds.
|
||||
pub mod ai;
|
||||
pub mod allay;
|
||||
pub mod ambient;
|
||||
pub mod anim;
|
||||
pub mod animal_brain;
|
||||
pub mod aquatic;
|
||||
@@ -1646,6 +1647,11 @@ impl Mob {
|
||||
pub fn species(&self) -> Species {
|
||||
self.kind.species
|
||||
}
|
||||
/// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's
|
||||
/// `mainHand` option for a player mob.
|
||||
pub fn main_arm_left(&self) -> bool {
|
||||
self.extra.main_hand_left || self.extra.left_handed
|
||||
}
|
||||
/// The player hit this mob (melee): LivingEntity.hurt with knockback.
|
||||
pub fn hurt(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! What a client's copy of a mob makes on its own, in the
|
||||
//! `level().isClientSide()` branches of `aiStep`: nothing of it reaches the
|
||||
//! server or goes over the network, so the window's game runs it, whether it
|
||||
//! simulates the world (in-frame) or follows a server.
|
||||
use super::{Mob, MobEvent, Species};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// `Enderman.aiStep`, `Endermite.aiStep` and `Blaze.aiStep` on a client:
|
||||
/// two portal particles a tick around endermen and endermites; two large
|
||||
/// smoke particles a tick around blazes and, one tick in 24, their burn.
|
||||
pub fn client_ai_step(m: &mut Mob, out: &mut Vec<MobEvent>) {
|
||||
let species = m.species();
|
||||
if !matches!(
|
||||
species,
|
||||
Species::Enderman | Species::Endermite | Species::Blaze
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
|
||||
let p = m.position;
|
||||
if species == Species::Blaze && m.random.next_int_bound(24).unwrap_or(1) == 0 && !m.silent {
|
||||
let volume = 1.0 + m.random.next_float();
|
||||
let pitch = m.random.next_float() * 0.7 + 0.3;
|
||||
out.push(MobEvent::LocalSound(
|
||||
"entity.blaze.burn",
|
||||
[p.x + 0.5, p.y + 0.5, p.z + 0.5],
|
||||
volume,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
// Entity.getRandomX(0.5), getRandomY(), getRandomZ(0.5).
|
||||
let r = &mut m.random;
|
||||
let x = p.x + w * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = p.y + h * r.next_double();
|
||||
let z = p.z + w * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let (kind, at, velocity) = if species == Species::Blaze {
|
||||
("large_smoke", [x, y, z], [0.0; 3])
|
||||
} else {
|
||||
// The enderman's are a quarter block lower.
|
||||
let y = if species == Species::Enderman {
|
||||
y - 0.25
|
||||
} else {
|
||||
y
|
||||
};
|
||||
let velocity = [
|
||||
(r.next_double() - 0.5) * 2.0,
|
||||
-r.next_double(),
|
||||
(r.next_double() - 0.5) * 2.0,
|
||||
];
|
||||
("portal", [x, y, z], velocity)
|
||||
};
|
||||
out.push(MobEvent::ParticleAt { kind, at, velocity });
|
||||
}
|
||||
}
|
||||
@@ -642,7 +642,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(ArmPose::Empty, main)
|
||||
} else {
|
||||
Hum::new(main, ArmPose::Empty)
|
||||
@@ -693,7 +693,7 @@ pub fn setup(
|
||||
} else {
|
||||
ArmPose::Empty
|
||||
};
|
||||
let mut h = if m.extra.main_hand_left {
|
||||
let mut h = if m.main_arm_left() {
|
||||
Hum::new(off, pose)
|
||||
} else {
|
||||
Hum::new(pose, off)
|
||||
|
||||
@@ -621,8 +621,10 @@ impl BowAttack {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.isHolding(Items.BOW)`: the bow can sit in either hand.
|
||||
fn holding_bow(m: &Mob) -> bool {
|
||||
m.extra.main_hand.as_deref() == Some("minecraft:bow")
|
||||
|| m.extra.off_hand.as_deref() == Some("minecraft:bow")
|
||||
}
|
||||
impl Goal for BowAttack {
|
||||
goal_clone!();
|
||||
@@ -715,7 +717,10 @@ impl Goal for BowAttack {
|
||||
} else {
|
||||
self.attack_time -= 1;
|
||||
if self.attack_time <= 0 && self.see_time >= -60 {
|
||||
// mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand):
|
||||
// the off hand when the main one doesn't hold the bow.
|
||||
m.extra.using_item = 1;
|
||||
m.extra.using_off_hand = m.extra.main_hand.as_deref() != Some("minecraft:bow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,21 +1277,22 @@ impl MotionWorld<'_, '_, '_> {
|
||||
continue;
|
||||
}
|
||||
let powder_snow = name == "minecraft:powder_snow";
|
||||
let lava_cauldron = name == "minecraft:lava_cauldron";
|
||||
// getEntityInsideCollisionShape is the whole block but for powder
|
||||
// snow (its collision shape, the whole block when it is empty),
|
||||
// filled cauldrons, end portals and tripwires; Horizon doesn't
|
||||
// follow the player into those last three.
|
||||
// follow the player into the water and powder snow cauldrons, end
|
||||
// portals or tripwires yet.
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:water_cauldron"
|
||||
| "minecraft:lava_cauldron"
|
||||
| "minecraft:powder_snow_cauldron"
|
||||
| "minecraft:end_portal"
|
||||
| "minecraft:tripwire"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let inside = !powder_snow || {
|
||||
let inside = !(powder_snow || lava_cauldron) || {
|
||||
let context = self.context();
|
||||
let binding = self.collision_binding;
|
||||
let shape = binding.collision_shape_with_context(
|
||||
@@ -1300,22 +1301,30 @@ impl MotionWorld<'_, '_, '_> {
|
||||
p,
|
||||
&context,
|
||||
)?;
|
||||
shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || {
|
||||
let boxes: Vec<_> = shape
|
||||
.moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)))
|
||||
.boxes()
|
||||
.to_vec();
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
powder_snow && (shape.is_empty() || shape.boxes() == VoxelShape::block().boxes())
|
||||
|| {
|
||||
let offset = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let mut boxes: Vec<_> = shape.moved(offset).boxes().to_vec();
|
||||
if lava_cauldron {
|
||||
// LavaCauldronBlock.FILLED_SHAPE: the cauldron's
|
||||
// shape or its lava, Block.column(12, 4, 15).
|
||||
boxes.push(Aabb::new(
|
||||
Vec3::new(2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0).add_vector(offset),
|
||||
Vec3::new(14.0 / 16.0, 15.0 / 16.0, 14.0 / 16.0).add_vector(offset),
|
||||
));
|
||||
}
|
||||
self.player
|
||||
.dimensions
|
||||
.bounds(from)
|
||||
.collided_along_vector(to.subtract(from), &boxes)
|
||||
}
|
||||
};
|
||||
if inside && visited.insert(p.pack()) {
|
||||
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`),
|
||||
// and WitherRoseBlock's server-side effect.
|
||||
// CactusBlock, CampfireBlock and LavaCauldronBlock.entityInside
|
||||
// (`entity.hurt`), and WitherRoseBlock's server-side effect.
|
||||
match name {
|
||||
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
|
||||
"minecraft:lava_cauldron" => self.lava_cauldron_inside()?,
|
||||
"minecraft:wither_rose" => {
|
||||
self.player.events.push(MotionEvent::InsideWitherRose);
|
||||
}
|
||||
@@ -1378,6 +1387,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
self.player.impulse.try_reset();
|
||||
}
|
||||
/// `LavaCauldronBlock.entityInside`: CLEAR_FREEZE, LAVA_IGNITE
|
||||
/// (`Entity.lavaIgnite`, 15 seconds) and then `Entity.lavaHurt` (lava,
|
||||
/// 4), as ordinary lava does to the player.
|
||||
fn lava_cauldron_inside(&mut self) -> Result<()> {
|
||||
self.player.ticks_frozen = 0;
|
||||
self.player.ignite_for_ticks(300)?;
|
||||
self.environment_hurt("minecraft:lava", 4.0)
|
||||
}
|
||||
/// `WebBlock.entityInside`: stuck at a quarter of the speed, half with
|
||||
/// the Weaving effect.
|
||||
fn web_inside(&mut self) {
|
||||
@@ -2392,6 +2409,54 @@ mod tests {
|
||||
assert_eq!(player.position.y, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lava_cauldron_burns_the_player_inside_it() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let cauldron = assets
|
||||
.registry
|
||||
.default_state("minecraft:lava_cauldron")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, cauldron).unwrap();
|
||||
let mut tick = |player: &mut PlayerMotion| {
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.controlled_simulation_tick(Default::default())
|
||||
.unwrap();
|
||||
};
|
||||
// Standing on the cauldron's floor, in its lava.
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.25, 20.5)).unwrap();
|
||||
player.ticks_frozen = 40;
|
||||
tick(&mut player);
|
||||
assert!(player.health.health() < 20.0, "the lava must hurt");
|
||||
assert!(player.fire_ticks > 0, "the lava must ignite");
|
||||
assert_eq!(player.ticks_frozen, 0);
|
||||
// Beside it, nothing.
|
||||
let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap();
|
||||
tick(&mut away);
|
||||
assert_eq!(away.health.health(), 20.0);
|
||||
assert!(away.fire_ticks <= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standing_in_a_wither_rose_asks_for_its_effect() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
|
||||
@@ -686,8 +686,14 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// en_us.json from the prepared resources.
|
||||
/// The language a session resolves server-sent components with: the selected
|
||||
/// one as `lang` loaded it (`en_us` under it, packs included), so a menu title
|
||||
/// the server sends as a key follows the language screen. A process with no
|
||||
/// language set (a dedicated server's log) reads en_us.json.
|
||||
pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
if let Some(lang) = crate::lang::current() {
|
||||
return lang.map().clone();
|
||||
}
|
||||
std::fs::read(resources.join("assets/minecraft/lang/en_us.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
|
||||
@@ -488,7 +488,12 @@ pub fn encode_block_entity(
|
||||
entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer)));
|
||||
entries.push(("cooking_total_time", Tag::Int(f.cooking_total)));
|
||||
entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits())));
|
||||
entries.push(("RecipesUsed", Tag::Compound(Compound::new())));
|
||||
let used: Compound = f
|
||||
.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32)))
|
||||
.collect();
|
||||
entries.push(("RecipesUsed", Tag::Compound(used)));
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
if let Some(speed) = float(tag.get("speed_multiplier")) {
|
||||
f.speed = speed as f32;
|
||||
}
|
||||
// A save without it owes nothing: the field is newer than some worlds.
|
||||
if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") {
|
||||
for (id, count) in used {
|
||||
let count = match count {
|
||||
Tag::Int(n) => *n,
|
||||
_ => continue,
|
||||
};
|
||||
f.recipes_used
|
||||
.insert(id.to_string_lossy(), count.max(0) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockEntity::Brewing(b) => {
|
||||
let items = decode_items(tag.get("Items"), 5);
|
||||
@@ -4276,4 +4292,21 @@ mod tests {
|
||||
.unwrap_or(0);
|
||||
assert_eq!(flying_survival, 0);
|
||||
}
|
||||
|
||||
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
|
||||
/// player nothing after a reload.
|
||||
#[test]
|
||||
fn a_furnaces_recipes_used_survive_a_save() {
|
||||
use crate::containers::BlockEntity;
|
||||
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
|
||||
panic!("a furnace");
|
||||
};
|
||||
f.recipes_used
|
||||
.insert("minecraft:cooked_porkchop".to_owned(), 3);
|
||||
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
|
||||
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
|
||||
panic!("a furnace back");
|
||||
};
|
||||
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
|
||||
}
|
||||
}
|
||||
@@ -2148,8 +2148,13 @@ mod cover_tests {
|
||||
}
|
||||
|
||||
/// Every question streaming asks gets the same answer with the grid.
|
||||
///
|
||||
/// A debug build checks every fourth cell of each row, shifted by row and case so the cases still cover every
|
||||
/// cell's position relative to the areas: the full check took 20 of the ~30 s of the crate's tests.
|
||||
/// `cargo test --release -p horizon-client --lib the_grid_answers_as_the_scan` checks every cell.
|
||||
#[test]
|
||||
fn the_grid_answers_as_the_scan() {
|
||||
let stride = if cfg!(debug_assertions) { 4 } else { 1 };
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
for case in 0..60 {
|
||||
@@ -2165,7 +2170,8 @@ mod cover_tests {
|
||||
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
|
||||
let span = [4, 30, 120][case % 3] + 40;
|
||||
for z in -span..=span {
|
||||
for x in -span..=span {
|
||||
let first = -span + (z + case as i32).rem_euclid(stride);
|
||||
for x in (first..=span).step_by(stride as usize) {
|
||||
for m in 0..=ExtraCover::REACH + 2 {
|
||||
assert_eq!(
|
||||
scan.in_extra(x, z, m),
|
||||
|
||||
@@ -428,6 +428,15 @@ impl Text {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `resolve_legacy` borrowing `self` when no span holds a `§` code (the
|
||||
/// common case).
|
||||
pub fn resolved(&self) -> std::borrow::Cow<'_, Text> {
|
||||
if self.spans.iter().any(|s| s.text.contains('§')) {
|
||||
std::borrow::Cow::Owned(self.resolve_legacy())
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(self)
|
||||
}
|
||||
}
|
||||
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
|
||||
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
|
||||
pub fn strip_legacy(&self) -> Text {
|
||||
@@ -695,4 +704,20 @@ mod tests {
|
||||
assert_eq!(t.spans[1].style.color, Some(GOLD));
|
||||
assert_eq!(t.slice(1, 2).plain(), "y");
|
||||
}
|
||||
#[test]
|
||||
fn resolved_borrows_clean_text_and_restyles_codes() {
|
||||
let clean = Text::colored("Steve", GOLD);
|
||||
assert!(matches!(clean.resolved(), std::borrow::Cow::Borrowed(_)));
|
||||
// A colour code inside a span clears its decorations (a bold
|
||||
// "§cSteve" draws "Steve" red, not bold).
|
||||
let styled = Style {
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let styled = Text::styled("§cSteve", styled);
|
||||
let resolved = styled.resolved();
|
||||
assert_eq!(resolved.plain(), "Steve");
|
||||
assert_eq!(resolved.spans[0].style.color, Some(RED));
|
||||
assert_eq!(resolved.spans[0].style.bold, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -279,9 +279,17 @@ pub fn centered_text(
|
||||
w: f32,
|
||||
color: u32,
|
||||
) {
|
||||
let width = atlas.font.width_text(text);
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
if width <= w {
|
||||
batch.text_styled(atlas, text, x + ((w - width) / 2.0).floor(), y, color, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
x + ((w - width) / 2.0).floor(),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
// Clip by characters (vanilla scrolls; we show the part that fits).
|
||||
let mut shown = Text::empty();
|
||||
|
||||
@@ -106,14 +106,16 @@ enum Palette {
|
||||
List(Vec<u32>),
|
||||
Global,
|
||||
}
|
||||
/// `n / per_word` for `n < 4096` by multiplication: `MAGIC[bits] = ceil(2^20 /
|
||||
/// (64 / bits))` is exact while `n * (64 / bits) < 2^20`.
|
||||
const MAGIC: [u32; 33] = {
|
||||
let mut table = [0; 33];
|
||||
/// `n / per_word` for `n < 4096` by multiplication: `MAGIC[bits].0 = ceil(2^20 /
|
||||
/// (64 / bits))` is exact while `n * (64 / bits) < 2^20`. `.1` is the bits a
|
||||
/// word's entries fill (`64 / bits * bits`): looked up, not divided for, as a
|
||||
/// division by a run-time value costs tens of cycles on x86.
|
||||
const MAGIC: [(u32, u32); 33] = {
|
||||
let mut table = [(0, 0); 33];
|
||||
let mut bits = 1;
|
||||
while bits <= 32 {
|
||||
let per = 64 / bits as u32;
|
||||
table[bits] = (1u32 << 20).div_ceil(per);
|
||||
table[bits] = ((1u32 << 20).div_ceil(per), per * bits as u32);
|
||||
bits += 1;
|
||||
}
|
||||
table
|
||||
@@ -134,9 +136,9 @@ fn locate(bits: u8, i: usize) -> (usize, u32) {
|
||||
let word = i >> (6 - log);
|
||||
(word, ((i & ((64 >> log) - 1)) as u32) << log)
|
||||
} else {
|
||||
let per = 64 / u32::from(bits);
|
||||
let word = (i as u32 * MAGIC[usize::from(bits)]) >> 20;
|
||||
(word as usize, (i as u32 - word * per) * u32::from(bits))
|
||||
let (magic, filled) = MAGIC[usize::from(bits)];
|
||||
let word = (i as u32 * magic) >> 20;
|
||||
(word as usize, i as u32 * u32::from(bits) - word * filled)
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
@@ -720,6 +722,17 @@ mod tests {
|
||||
use super::*;
|
||||
const BLOCKS: Strategy = Strategy::blocks(16);
|
||||
const BIOMES: Strategy = Strategy::biomes(7);
|
||||
|
||||
#[test]
|
||||
fn locate_matches_division_for_every_width() {
|
||||
for bits in 1..=32u8 {
|
||||
let per = 64 / usize::from(bits);
|
||||
for i in 0..4096 {
|
||||
let expected = (i / per, ((i % per) * usize::from(bits)) as u32);
|
||||
assert_eq!(locate(bits, i), expected, "{bits} bits, entry {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
|
||||
@@ -7,10 +7,15 @@ rust-version = "1.85"
|
||||
description = "The DualSense's output over HID: rumble, adaptive triggers, lightbar and player LEDs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
|
||||
# hidapi has no iOS backend and its build script stops there, so the output's two
|
||||
# dependencies go only to the targets that have one. On iOS the crate builds without
|
||||
# output: the Link never opens.
|
||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||
# MIT; bundles the C hidapi under its BSD licence option. hidraw on Linux (libudev, as gilrs),
|
||||
# IOKit on macOS (opened shared, so gilrs keeps reading the pad), the HID API on Windows.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-static-hidraw", "macos-shared-device"] }
|
||||
log = "0.4"
|
||||
# HD haptics through the pad's own audio device (USB).
|
||||
cpal = "0.16"
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! The pad over hidapi, and the thread that writes to it: writes block (a Bluetooth report can
|
||||
//! take milliseconds), so the game only hands over what the pad should do.
|
||||
use crate::{
|
||||
DEFAULT_LIGHTBAR, Output, PLAYER_1, TRIGGER_OFF, TriggerEffect, Vibration, is_dualsense,
|
||||
};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use crate::{DEFAULT_LIGHTBAR, Output, PLAYER_1, Vibration, is_dualsense};
|
||||
use crate::{TRIGGER_OFF, TriggerEffect};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use hidapi::{BusType, HidApi, HidDevice, HidResult};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
@@ -17,6 +24,7 @@ pub enum Transport {
|
||||
}
|
||||
|
||||
/// An open DualSense.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub struct DualSense {
|
||||
device: HidDevice,
|
||||
transport: Transport,
|
||||
@@ -34,6 +42,7 @@ pub fn bluetooth_allowed() -> bool {
|
||||
cfg!(target_os = "linux") || std::env::var_os("HORIZON_DUALSENSE_BT").is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
impl DualSense {
|
||||
/// The first DualSense hidapi lists, USB before Bluetooth (as SDL); `None` when there is
|
||||
/// none, an error when it can't be opened (Linux without access to its hidraw node).
|
||||
@@ -119,13 +128,17 @@ impl Default for State {
|
||||
/// A vibration: the motors' levels (left, right), each for a while, in order.
|
||||
pub type Rumble = Vec<([u8; 2], Duration)>;
|
||||
|
||||
// On iOS no thread reads the queue (the Link never opens); the variants stay so the
|
||||
// Link's API is the same everywhere.
|
||||
#[cfg_attr(target_os = "ios", allow(dead_code))]
|
||||
enum Message {
|
||||
State(State),
|
||||
Rumble(Rumble),
|
||||
Scan,
|
||||
}
|
||||
|
||||
/// The writer thread. Dropping it frees the triggers and stops the motors before it ends.
|
||||
/// The output thread. Dropping it frees the triggers and stops the motors before it ends; on
|
||||
/// iOS there is none (no hidapi backend) and the Link stays closed.
|
||||
pub struct Link {
|
||||
tx: Option<Sender<Message>>,
|
||||
/// 0: no pad open, else 1 + the transport.
|
||||
@@ -135,6 +148,7 @@ pub struct Link {
|
||||
|
||||
impl Link {
|
||||
/// Start the thread and look for a DualSense.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn start() -> Link {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let open = Arc::new(AtomicU8::new(0));
|
||||
@@ -151,6 +165,16 @@ impl Link {
|
||||
}
|
||||
}
|
||||
|
||||
/// No output on iOS: hidapi has no backend there. The Link never opens.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn start() -> Link {
|
||||
Link {
|
||||
tx: None,
|
||||
open: Arc::new(AtomicU8::new(0)),
|
||||
thread: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pad's transport while one is open and taking reports.
|
||||
pub fn open(&self) -> Option<Transport> {
|
||||
match self.open.load(Ordering::Relaxed) {
|
||||
@@ -194,8 +218,10 @@ impl Drop for Link {
|
||||
|
||||
/// Looks for the pad this many more times, a second apart, after a scan finds nothing (its
|
||||
/// hidraw node may appear after the controller does).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
const SCAN_TRIES: u32 = 5;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
struct Writer {
|
||||
api: Option<HidApi>,
|
||||
pad: Option<DualSense>,
|
||||
@@ -213,6 +239,7 @@ struct Writer {
|
||||
next_scan: Instant,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Lights {
|
||||
/// Over Bluetooth, until the pad's clock says its animation is over (or this long after
|
||||
@@ -225,11 +252,13 @@ enum Lights {
|
||||
|
||||
/// The pad's sensor clock once its Bluetooth connection animation is over (SDL_hidapi_ps5.c's
|
||||
/// `HIDAPI_DriverPS5_CheckPendingLEDReset`).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
const ANIMATION_OVER: u32 = 10_200_000;
|
||||
|
||||
/// The sensor clock of a Bluetooth input report 0x31: its state block starts at byte 2 and
|
||||
/// holds the clock at 27..31, little-endian (SDL's `PS5StatePacketCommon_t`, the kernel's
|
||||
/// `dualsense_input_report`).
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn bluetooth_clock(report: &[u8]) -> Option<u32> {
|
||||
match report {
|
||||
[0x31, _, state @ ..] if state.len() >= 31 => {
|
||||
@@ -239,6 +268,7 @@ fn bluetooth_clock(report: &[u8]) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
impl Writer {
|
||||
fn new(open: Arc<AtomicU8>) -> Self {
|
||||
Writer {
|
||||
@@ -451,7 +481,7 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_os = "ios")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! No HD haptics on iOS: they play through the pad's own four-channel audio device over USB,
|
||||
//! which needs cpal, and cpal is left out there with hidapi (see Cargo.toml).
|
||||
|
||||
/// The pad's audio output, playing the queued samples (left, right actuator).
|
||||
pub struct Haptics;
|
||||
|
||||
impl Haptics {
|
||||
/// The DualSense's audio device, if one is there with its four channels (USB). A phone
|
||||
/// has none.
|
||||
pub fn open() -> Option<Haptics> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Play a vibration (heavy and light levels, 0..1, each for a while), replacing the one
|
||||
/// playing. Nothing plays on iOS.
|
||||
pub fn play(&self, _steps: &[(f32, f32, std::time::Duration)]) {}
|
||||
|
||||
/// Stop at once. Nothing plays on iOS.
|
||||
pub fn stop(&self) {}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
//! The DualSense's output report, built by hand (docs/controllers.md "DualSense output"): the
|
||||
//! compatible-vibration motors, the adaptive triggers, the lightbar and the player LEDs. Gilrs
|
||||
//! keeps reading the pad; this crate only writes to it, over hidapi ([`device`]).
|
||||
//! On iOS the crate builds without hidapi, which has no backend there: [`Link`] never opens.
|
||||
//!
|
||||
//! The byte layout is the one documented by the Linux kernel's `hid-playstation.c` and SDL's
|
||||
//! `SDL_hidapi_ps5.c`: a 47-byte block shared by both transports, sent as report 0x02 over USB
|
||||
//! and as report 0x31 over Bluetooth, where a sequence tag leads it and a CRC-32 (seeded with
|
||||
//! the byte 0xA2) closes it.
|
||||
pub mod device;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub mod haptics;
|
||||
#[cfg(target_os = "ios")]
|
||||
#[path = "haptics_stub.rs"]
|
||||
pub mod haptics;
|
||||
pub mod trigger;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Example Horizon mod: a missile you launch with /missile and steer with the mouse"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
id = "predator_missile"
|
||||
name = "Predator Missile"
|
||||
version = "0.1.0"
|
||||
description = "/missile launches a guided missile 60 blocks above you. Steer it with the mouse; it explodes on whatever it hits. Needs cheats (it runs vanilla commands as you)."
|
||||
authors = ["Horizon"]
|
||||
api = "0.1"
|
||||
side = "client"
|
||||
capabilities = ["game-read", "hud", "chat", "command"]
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Example Horizon mod (`docs/mods.md`): `/missile` turns you into a guided
|
||||
//! missile 60 blocks up. You fly along your own look, so the mouse steers;
|
||||
//! on a block or a mob a stack of primed TNT goes off, and you are put back
|
||||
//! where you launched. Built from vanilla commands (capability `command`),
|
||||
//! so it needs cheats.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration,
|
||||
command::run,
|
||||
game::{self, Player},
|
||||
hud::{self, Corner, Line},
|
||||
};
|
||||
|
||||
/// Blocks per tick (a fired arrow does about 3).
|
||||
const SPEED: f64 = 1.5;
|
||||
const ALTITUDE: i32 = 60;
|
||||
/// Ticks of flight before the missile detonates on its own.
|
||||
const FUEL: u32 = 30 * 20;
|
||||
/// Ticks to watch the blast before going back.
|
||||
const AFTERMATH: u32 = 40;
|
||||
/// Primed TNT per blast: the stack's explosions add up.
|
||||
const WARHEAD: usize = 4;
|
||||
/// What the impact checks print; the mod hears it through the chat hook.
|
||||
const IMPACT: &str = "Predator: impact";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Flying { from: Player, ticks: u32 },
|
||||
Aftermath { from: Player, ticks: u32 },
|
||||
}
|
||||
|
||||
struct Predator {
|
||||
state: State,
|
||||
}
|
||||
|
||||
fn launch() {
|
||||
run("gamerule send_command_feedback false");
|
||||
run("execute if entity @s[gamemode=creative] run tag @s add pm_creative");
|
||||
run("gamemode spectator");
|
||||
run(&format!("tp @s ~ ~{ALTITUDE} ~ ~ 60"));
|
||||
run("effect give @s night_vision 60 0 true");
|
||||
// The thermal camera: every mob outlined.
|
||||
run("effect give @e[type=!player,distance=..256] glowing 60 0 true");
|
||||
}
|
||||
|
||||
/// One tick of flight: stop at the surface (the next step would enter a
|
||||
/// block or a mob), else move one step along the look.
|
||||
fn fly() {
|
||||
run(&format!(
|
||||
"execute at @s unless block ^ ^ ^{SPEED} #minecraft:air run tellraw @s \"{IMPACT}\""
|
||||
));
|
||||
run(&format!(
|
||||
"execute at @s positioned ^ ^ ^{SPEED} if entity @e[type=!player,distance=..2] run tellraw @s \"{IMPACT}\""
|
||||
));
|
||||
run(&format!(
|
||||
"execute at @s if block ^ ^ ^{SPEED} #minecraft:air run tp @s ^ ^ ^{SPEED}"
|
||||
));
|
||||
}
|
||||
|
||||
fn detonate() {
|
||||
for _ in 0..WARHEAD {
|
||||
run("execute at @s run summon tnt ^ ^ ^1 {fuse:0s}");
|
||||
}
|
||||
}
|
||||
|
||||
fn land(from: &Player) {
|
||||
run(&format!(
|
||||
"tp @s {} {} {} {} {}",
|
||||
from.x, from.y, from.z, from.yaw, from.pitch
|
||||
));
|
||||
run("execute if entity @s[tag=pm_creative] run gamemode creative");
|
||||
run("execute unless entity @s[tag=pm_creative] run gamemode survival");
|
||||
run("execute if entity @s[tag=pm_creative] run tag @s remove pm_creative");
|
||||
run("effect clear @s night_vision");
|
||||
// shortcut: assumes feedback was on before launch, as in a new world.
|
||||
run("gamerule send_command_feedback true");
|
||||
}
|
||||
|
||||
fn panel(p: &Player, ticks: u32) -> Vec<Line> {
|
||||
let green = 0x7CFF7C;
|
||||
let left = (FUEL - ticks.min(FUEL)) / 20;
|
||||
[
|
||||
"PREDATOR".to_string(),
|
||||
format!("ALT {:.0}", p.y),
|
||||
format!("FUEL {left}s"),
|
||||
"Mouse to steer, /missile to detonate".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|text| Line { text, color: green })
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Predator {
|
||||
fn impact(&mut self) {
|
||||
if let State::Flying { from, .. } = &self.state {
|
||||
detonate();
|
||||
self.state = State::Aftermath {
|
||||
from: from.clone(),
|
||||
ticks: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mod for Predator {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = Registration::new().command("missile").on_tick().on_chat();
|
||||
Ok((Predator { state: State::Idle }, hooks))
|
||||
}
|
||||
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
match &mut self.state {
|
||||
State::Idle => {}
|
||||
State::Flying { ticks, .. } => {
|
||||
*ticks += 1;
|
||||
let ticks = *ticks;
|
||||
if ticks >= FUEL {
|
||||
return self.impact();
|
||||
}
|
||||
// The first tick waits for the launch teleport.
|
||||
if ticks > 1 {
|
||||
fly();
|
||||
}
|
||||
if let Some(p) = game::get_player() {
|
||||
hud::set_panel(Corner::TopLeft, &panel(&p, ticks));
|
||||
}
|
||||
}
|
||||
State::Aftermath { from, ticks } => {
|
||||
*ticks += 1;
|
||||
if *ticks >= AFTERMATH {
|
||||
land(from);
|
||||
hud::clear();
|
||||
self.state = State::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
if text.contains(IMPACT) {
|
||||
self.impact();
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, _name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
match self.state {
|
||||
State::Idle => {
|
||||
let from = game::get_player().ok_or("Not in a world")?;
|
||||
launch();
|
||||
self.state = State::Flying { from, ticks: 0 };
|
||||
Ok(Some("Missile away".into()))
|
||||
}
|
||||
State::Flying { .. } => {
|
||||
self.impact();
|
||||
Ok(None)
|
||||
}
|
||||
State::Aftermath { .. } => Err("Wait for the blast to clear".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Predator);
|
||||
@@ -21,7 +21,7 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `ui`).
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`).
|
||||
//!
|
||||
//! Widgets (capability `ui`): a mod shows a screen it owns with
|
||||
//! [`ui::open_screen`] and a page in Options > Mods with
|
||||
@@ -38,7 +38,7 @@ wit_bindgen::generate!({
|
||||
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
|
||||
pub use horizon::mods::{chat, game, hud, log, storage, ui};
|
||||
pub use horizon::mods::{chat, command, game, hud, log, storage, ui};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
|
||||
@@ -86,6 +86,14 @@ interface storage {
|
||||
delete: func(key: string);
|
||||
}
|
||||
|
||||
/// Capability `command`: vanilla commands run as the player, with the
|
||||
/// player's permissions, after the hook call returns (8 per call). A mod's
|
||||
/// own commands are not run this way.
|
||||
interface command {
|
||||
/// `line` without its slash; its feedback goes to the chat as if typed.
|
||||
run: func(line: string);
|
||||
}
|
||||
|
||||
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
|
||||
/// a screen the mod owns and a settings page in Options > Mods. Layout and
|
||||
/// drawing are the game's: a mod says what its widgets hold, never where
|
||||
@@ -194,6 +202,7 @@ world horizon-mod {
|
||||
import chat;
|
||||
import hud;
|
||||
import storage;
|
||||
import command;
|
||||
export events;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ pub enum Capability {
|
||||
Chat,
|
||||
Hud,
|
||||
Storage,
|
||||
/// Runs vanilla commands as the player, with the player's permissions.
|
||||
Command,
|
||||
/// New blocks and items from the mod's `data/` (API 0.2): no WIT
|
||||
/// interface, the game reads them before the world loads.
|
||||
Registries,
|
||||
@@ -86,11 +88,12 @@ pub enum Capability {
|
||||
Ui,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 6] = [
|
||||
pub const ALL: [Capability; 7] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
Capability::Storage,
|
||||
Capability::Command,
|
||||
Capability::Registries,
|
||||
Capability::Ui,
|
||||
];
|
||||
@@ -101,6 +104,7 @@ impl Capability {
|
||||
Capability::Chat => "chat",
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Command => "command",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
}
|
||||
@@ -117,6 +121,7 @@ impl Capability {
|
||||
Capability::Chat => "show you chat messages",
|
||||
Capability::Hud => "draw text on your screen",
|
||||
Capability::Storage => "save its own settings to disk",
|
||||
Capability::Command => "run commands as you, with your permissions",
|
||||
Capability::Registries => "add blocks and items to your worlds",
|
||||
Capability::Ui => "show its own screens and a settings page",
|
||||
}
|
||||
@@ -148,6 +153,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Chat => "chat",
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Command => "command",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
};
|
||||
@@ -397,6 +403,10 @@ capabilities = ["game-read", "hud"]
|
||||
storage,
|
||||
Err("uses capability storage, missing from horizon-mod.toml".into())
|
||||
);
|
||||
assert_eq!(
|
||||
m.check_imports(["horizon:mods/[email protected]"]),
|
||||
Err("uses capability command, missing from horizon-mod.toml".into())
|
||||
);
|
||||
let with_storage = EXAMPLE.replace("\"hud\"", "\"hud\", \"storage\"");
|
||||
let m = Manifest::parse(&with_storage).unwrap();
|
||||
assert_eq!(m.check_imports(["horizon:mods/[email protected]"]), Ok(()));
|
||||
|
||||
@@ -32,7 +32,7 @@ pub use bindings::horizon::mods::{
|
||||
use bindings::{
|
||||
HorizonMod,
|
||||
exports::horizon::mods::events::Registration,
|
||||
horizon::mods::{chat, game, hud, log as mod_log, storage},
|
||||
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
|
||||
};
|
||||
/// The widget model a `ui` screen or settings page shows (WIT `interface
|
||||
/// ui`), for the game to draw with its menus' own widgets.
|
||||
@@ -79,6 +79,9 @@ pub enum Output {
|
||||
Chat { name: String, text: String },
|
||||
/// A mod command's feedback, red when `error`.
|
||||
Feedback { text: String, error: bool },
|
||||
/// `command.run`: a vanilla command line, without its slash, to run as
|
||||
/// the player.
|
||||
Command { line: String },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
|
||||
@@ -668,6 +671,18 @@ impl chat::Host for State {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl command::Host for State {
|
||||
fn run(&mut self, line: String) {
|
||||
let queued = self
|
||||
.out
|
||||
.iter()
|
||||
.filter(|o| matches!(o, Output::Command { .. }))
|
||||
.count();
|
||||
if queued < CHAT_PER_CALL {
|
||||
self.out.push(Output::Command { line: clip(line) });
|
||||
}
|
||||
}
|
||||
}
|
||||
impl hud::Host for State {
|
||||
fn set_panel(&mut self, corner: Corner, lines: Vec<Line>) {
|
||||
let lines = lines
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Loads real mods built with the SDK: the coords-hud example and a guest
|
||||
//! Loads real mods built with the SDK: the coords-hud and predator-missile
|
||||
//! examples and a guest
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
Clock, Corner, Mods, Output, Package, Player, Snapshot, Status, manifest::componentize,
|
||||
@@ -26,7 +27,7 @@ capabilities = ["chat", "ui"]
|
||||
experimental = ["ui"]
|
||||
"#;
|
||||
|
||||
/// Builds both mods once and writes their `.hmod` files; returns the folder.
|
||||
/// Builds the mods once and writes their `.hmod` files; returns the folder.
|
||||
fn mods_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
@@ -39,6 +40,8 @@ fn mods_dir() -> &'static Path {
|
||||
"-p",
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"predator-missile",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-ui-guest",
|
||||
@@ -54,9 +57,13 @@ fn mods_dir() -> &'static Path {
|
||||
let built = target.join("wasm32-unknown-unknown/release");
|
||||
let dir = target.join("mods");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let example = root.join("crates/horizon-mod-sdk/examples/coords-hud/horizon-mod.toml");
|
||||
let example = |name: &str| {
|
||||
let path = format!("crates/horizon-mod-sdk/examples/{name}/horizon-mod.toml");
|
||||
std::fs::read_to_string(root.join(path)).unwrap()
|
||||
};
|
||||
let mods = [
|
||||
("coords_hud", std::fs::read_to_string(example).unwrap()),
|
||||
("coords_hud", example("coords-hud")),
|
||||
("predator_missile", example("predator-missile")),
|
||||
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
|
||||
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
|
||||
];
|
||||
@@ -92,6 +99,36 @@ fn player() -> Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability `command`: `/missile` queues vanilla commands, and the chat
|
||||
/// line its impact check prints sets off the warhead.
|
||||
#[test]
|
||||
fn predator_missile_runs_commands() {
|
||||
let mut mods = load("predator_missile.hmod");
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
mods.set_snapshot(player());
|
||||
let commands = |mods: &mut Mods| -> Vec<String> {
|
||||
mods.drain()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Output::Command { line } => Some(line),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert!(mods.command("missile"));
|
||||
let launch = commands(&mut mods);
|
||||
assert_eq!(launch.len(), 6);
|
||||
assert!(launch.contains(&"gamemode spectator".to_string()));
|
||||
mods.tick(1);
|
||||
mods.tick(2);
|
||||
let flight = commands(&mut mods);
|
||||
assert!(flight.iter().any(|l| l.ends_with("run tp @s ^ ^ ^1.5")));
|
||||
mods.chat("Predator: impact");
|
||||
let blast = commands(&mut mods);
|
||||
assert_eq!(blast.len(), 4);
|
||||
assert!(blast.iter().all(|l| l.contains("summon tnt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coords_hud_draws_and_answers() {
|
||||
let mut mods = load("coords_hud.hmod");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# OUTPUT_VERSION (src/lib.rs) and the digest of what it prepares (python3 tools/prepare_output.py digest),
|
||||
# one line per version, never edited: a change to the prepared files appends the next version.
|
||||
1 0a608bf4115a8ed2e2c4f5f2b2609dc10c54e63479af8e56fabaa10af1060389
|
||||
@@ -31,6 +31,15 @@ pub type Log<'a> = &'a dyn Fn(&str);
|
||||
/// The manifest's `schema`: the prepared files' layout, which the launcher compares.
|
||||
pub const SCHEMA: i64 = 7;
|
||||
|
||||
/// What this preparer makes. The launcher copies a prepared version's resources to a newly installed
|
||||
/// version whose preparer has the same number (and the same pinned jars), so a release that changes
|
||||
/// this crate without changing its output asks nobody to prepare again. Bump it, and append the new
|
||||
/// digest to output-versions.txt, when the prepared files change: CI prepares with every change to
|
||||
/// the preparer and fails when the output is not the one output-versions.txt gives this number
|
||||
/// (`python3 tools/prepare_output.py check`). The launcher reads this line from the version
|
||||
/// folder's sources: keep it on one line, as it is.
|
||||
pub const OUTPUT_VERSION: u32 = 1;
|
||||
|
||||
/// Digest of this crate's sources (build.rs): the launcher prepares a version in-process when
|
||||
/// that version ships these same sources, and runs the version's own binary otherwise.
|
||||
pub const SOURCES_SHA256: &str = env!("HORIZON_PREPARE_SOURCES_SHA256");
|
||||
@@ -1209,6 +1218,29 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_versions_end_at_the_output_version() {
|
||||
let text = include_str!("../output-versions.txt");
|
||||
let versions: Vec<(u32, &str)> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
|
||||
.map(|l| {
|
||||
let (n, digest) = l.split_once(' ').unwrap();
|
||||
(n.parse().unwrap(), digest.trim())
|
||||
})
|
||||
.collect();
|
||||
let numbers: Vec<u32> = versions.iter().map(|v| v.0).collect();
|
||||
assert_eq!(numbers, (1..=OUTPUT_VERSION).collect::<Vec<_>>());
|
||||
for (i, (_, digest)) in versions.iter().enumerate() {
|
||||
assert_eq!(digest.len(), 64);
|
||||
assert!(!versions[..i].iter().any(|(_, d)| d == digest));
|
||||
}
|
||||
// The launcher finds the number with this line in a version folder's sources.
|
||||
let lib = include_str!("lib.rs");
|
||||
let line = format!("pub const OUTPUT_VERSION: u32 = {OUTPUT_VERSION};");
|
||||
assert!(lib.lines().any(|l| l == line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_digest_is_the_one_build_rs_computed() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -39,6 +39,8 @@ pub mod component {
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
pub const FIREWORK_EXPLOSION: i32 = 70;
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
}
|
||||
|
||||
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
|
||||
@@ -89,6 +91,13 @@ pub enum Component {
|
||||
WrittenBook(WrittenBook),
|
||||
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
|
||||
Stacks(Vec<Stack>),
|
||||
/// `Fireworks`: flight duration and its explosions.
|
||||
Fireworks {
|
||||
flight: i32,
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
},
|
||||
/// `FireworkExplosion` (a firework star).
|
||||
FireworkExplosion(FireworkExplosion),
|
||||
/// `ArmorTrim` naming its material and pattern by registry id (`trim_material`,
|
||||
/// `trim_pattern`); a trim with a direct holder stays [`Component::Raw`].
|
||||
Trim {
|
||||
@@ -100,6 +109,41 @@ pub enum Component {
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
/// `FireworkExplosion.STREAM_CODEC`: shape id, colors, fade colors, trail
|
||||
/// and twinkle (FireworkExplosion.Shape's BY_ID order).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FireworkExplosion {
|
||||
pub shape: i32,
|
||||
pub colors: Vec<i32>,
|
||||
pub fade: Vec<i32>,
|
||||
pub trail: bool,
|
||||
pub twinkle: bool,
|
||||
}
|
||||
impl FireworkExplosion {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let ints = |r: &mut Reader| -> Result<Vec<i32>> {
|
||||
let n = r.count(1024)?;
|
||||
(0..n).map(|_| r.i32()).collect()
|
||||
};
|
||||
Ok(Self {
|
||||
shape: r.varint()?,
|
||||
colors: ints(r)?,
|
||||
fade: ints(r)?,
|
||||
trail: r.bool()?,
|
||||
twinkle: r.bool()?,
|
||||
})
|
||||
}
|
||||
fn write(&self, w: &mut Writer) {
|
||||
w.varint(self.shape);
|
||||
w.varint(self.colors.len() as i32);
|
||||
self.colors.iter().for_each(|c| w.i32(*c));
|
||||
w.varint(self.fade.len() as i32);
|
||||
self.fade.iter().for_each(|c| w.i32(*c));
|
||||
w.bool(self.trail);
|
||||
w.bool(self.twinkle);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
/// (dropped).
|
||||
fn filterable<T>(r: &mut Reader, value: impl Fn(&mut Reader) -> Result<T>) -> Result<T> {
|
||||
@@ -260,6 +304,17 @@ impl Component {
|
||||
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
FIREWORKS => {
|
||||
let flight = r.varint()?;
|
||||
let n = r.count(256)?;
|
||||
Self::Fireworks {
|
||||
flight,
|
||||
explosions: (0..n)
|
||||
.map(|_| FireworkExplosion::read(r))
|
||||
.collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
FIREWORK_EXPLOSION => Self::FireworkExplosion(FireworkExplosion::read(r)?),
|
||||
// ArmorTrim.STREAM_CODEC: two holders, each a reference's id plus
|
||||
// one. A direct material or pattern has no registry name: read past.
|
||||
TRIM => {
|
||||
@@ -375,6 +430,12 @@ impl Component {
|
||||
w.varint(stacks.len() as i32);
|
||||
stacks.iter().for_each(|s| Template::write(s, w));
|
||||
}
|
||||
Self::Fireworks { flight, explosions } => {
|
||||
w.varint(*flight);
|
||||
w.varint(explosions.len() as i32);
|
||||
explosions.iter().for_each(|e| e.write(w));
|
||||
}
|
||||
Self::FireworkExplosion(e) => e.write(w),
|
||||
Self::WritableBook(pages) => {
|
||||
w.varint(pages.len() as i32);
|
||||
for page in pages {
|
||||
@@ -670,6 +731,44 @@ mod tests {
|
||||
round::<Option<Hashed>, HashedSlot>(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireworks_components_round_trip() {
|
||||
let mut rocket = Stack::new(42, 1);
|
||||
rocket.added.push((
|
||||
component::FIREWORKS,
|
||||
Component::Fireworks {
|
||||
flight: 3,
|
||||
explosions: vec![
|
||||
FireworkExplosion {
|
||||
shape: 4,
|
||||
colors: vec![0xff0000, 0x00ff00],
|
||||
fade: vec![0x0000ff],
|
||||
trail: true,
|
||||
twinkle: false,
|
||||
},
|
||||
FireworkExplosion {
|
||||
shape: 0,
|
||||
colors: vec![],
|
||||
fade: vec![],
|
||||
trail: false,
|
||||
twinkle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
));
|
||||
rocket.added.push((
|
||||
component::FIREWORK_EXPLOSION,
|
||||
Component::FireworkExplosion(FireworkExplosion {
|
||||
shape: 2,
|
||||
colors: vec![7],
|
||||
fade: vec![],
|
||||
trail: false,
|
||||
twinkle: false,
|
||||
}),
|
||||
));
|
||||
round::<Option<Stack>, Slot>(Some(rocket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charged_projectiles_are_item_stack_templates() {
|
||||
// A crossbow (id 7) loaded with one arrow (id 9): ChargedProjectiles
|
||||
|
||||
@@ -11,19 +11,20 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
|
||||
| `chat` | [`chat`](#chat) | show you chat messages |
|
||||
| `hud` | [`hud`](#hud) | draw text on your screen |
|
||||
| `storage` | [`storage`](#storage) | save its own settings to disk |
|
||||
| `command` | [`command`](#command) | run commands as you, with your permissions |
|
||||
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
|
||||
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Exports (what the mod provides): `events`
|
||||
|
||||
## World `horizon-ui-mod`
|
||||
|
||||
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
|
||||
|
||||
- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage`, `command`
|
||||
- Exports (what the mod provides): `ui-events`, `events`
|
||||
|
||||
## World `ui-host`
|
||||
@@ -132,6 +133,16 @@ Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/m
|
||||
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
|
||||
| `delete` | `func(key: string)` | No error if `key` was already absent. |
|
||||
|
||||
## `command`
|
||||
|
||||
Access: capability `command`.
|
||||
|
||||
Capability `command`: vanilla commands run as the player, with the player's permissions, after the hook call returns (8 per call). A mod's own commands are not run this way.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. |
|
||||
|
||||
## `ui`
|
||||
|
||||
Access: capability `ui`.
|
||||
|
||||
@@ -86,7 +86,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
|
||||
| Part | Level | Version |
|
||||
| --- | --- | --- |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `command`, `events` | preview | 0.1.0 |
|
||||
| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
|
||||
|
||||
+3
-2
@@ -12,9 +12,9 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
|
||||
| Part | Where | State |
|
||||
|---|---|---|
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, ui; tick, frame, chat and command hooks |
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
|
||||
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / check`, and the registry's CI (`registry build / review / list`) |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
@@ -86,6 +86,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
|
||||
| Hook (export) | When |
|
||||
|
||||
@@ -20,6 +20,8 @@ python3 tools/parity.py --changed origin/main # those a branch's changes ca
|
||||
python3 tools/parity.py --changed-files paths.txt # the same for listed paths, in a copy without Git (tools/pr_ready.py)
|
||||
```
|
||||
|
||||
On CI, a pull request runs the parity job only when it changes a harness, an oracle, a probe or the runner; everything else is checked on main by `parity-main`, a batch of merges at a time, which bisects a failure to the merge that caused it (`tools/parity_main.py`; [release-flow.md](release-flow.md#parity-on-main)). Game releases wait for it.
|
||||
|
||||
Each harness runs unmodified under a tracer that records the files it depended on: its Python modules, its Java oracle and the oracle sources javac pulled in, other repository files it read, and the Rust sources of the probes it ran (from Cargo's `.d` dep-info files). A pass stands until one of those files, the pinned vanilla artifacts, `Cargo.lock` or the Rust/Java toolchains change. State and logs are in `.local/parity-runner/`; each harness's own outputs stay in its `.local/<name>/` directory.
|
||||
|
||||
Vanilla oracle runs are reused as well. `tools/parity_shim.py` stands in for `java` and `javac`: it records each oracle run's outputs and the content of every file under the paths its arguments name, and replays the run while those are unchanged. After a Rust-only change a harness pays only for its Rust side (`block_update`: 53 s to 5 s). Files an oracle writes outside its named paths aren't tracked, so a harness that fails while reusing oracle results is rerun fresh, and stops reusing them if it then passes. `--all` and `--fresh` never reuse them.
|
||||
@@ -49,6 +51,10 @@ A harness can declare, at the top level of its script:
|
||||
- `PARITY_SHARDS = N`: it runs as `NAME#1` to `NAME#N`, in parallel, each told its share in `PARITY_SHARD=i/N` (`test_datafix_parity.py` upgrades every sixth world). `NAME` on the command line or in `--known-failures` means all of them.
|
||||
- `PARITY_RUST_SCOPE = [...]`: the Rust sources its probes can reach, as path prefixes. Cargo's dep-info names every file of every crate a probe links, so without it a probe of `horizon-world` depends on all 350 of its files. The runner keeps only the scope's files if the scope is closed: nothing in it names a module, crate-root re-export or macro outside it (`crate::`, `super::`, other workspace crates; comments and strings ignored), declares a module it doesn't cover, or includes source, and nothing outside it implements anything for a type it defines. Crate roots then count without their declarations of other modules, so adding a module elsewhere doesn't rerun the harness. If the scope isn't closed the runner says why and keeps every file. `datafix` depends on 41 files this way, not 354.
|
||||
|
||||
### Which harnesses a change affects
|
||||
|
||||
`--changed` and `--changed-files` narrow each harness's Rust sources to those its probes can reach (`tools/parity_reach.py`), so a change to one module of `horizon-client` no longer reruns every harness whose probe links the crate. The narrowing works on items, not files: from the probe modules a harness ran (or the binary it built), a free item (fn, type, const, static) is reached when a reached item names it in its own module, through a path or an import of a module that is named, or through a glob import; an inherent impl's methods count one by one once their type is reached and their name is mentioned; a trait impl counts once its trait and its type are reached (std's traits and types always are); a macro counts once invoked. Names match by spelling, so the result only over-approximates. A reached file's parent modules count through their `mod` declarations of it, compared with the merge base; Rust files a change adds take part, a dependency that was deleted counts as changed, code under `#[cfg(test)]` never counts, and a harness whose probe can't be found keeps every source. Parses are cached by content in `.local/parity-runner/reach-cache.json`. `--by-crate` turns the narrowing off. On the last 20 pull requests merged on 2026-10-25, the harnesses picked went from 2255 to 1061 (2384 to 1592 harness-minutes; game-binary harnesses, which link all of `game/`, set the floor), and every harness a CI parity run failed in the week before was still picked; every source file a sample of probes executed (LLVM `-C instrument-coverage`) is among those kept. Building the graph takes 15 s cold, under a second warm, and up to a minute and a half on a change that reaches many harnesses.
|
||||
|
||||
## The gate
|
||||
|
||||
`tools/gate.py` is what a branch must pass before it is integrated: the public-tree check, rustfmt, clippy with warnings denied, the Rust and Python tests, then the harnesses whose inputs changed. It stops at the first failure and writes `.local/gate.json`. `--known-failures a,b` lets harnesses that already fail on the integration branch keep failing. `--quick` checks formatting, lints and Python tests only; `--no-parity` skips the harnesses.
|
||||
|
||||
+34
-3
@@ -13,7 +13,7 @@ reopened; labels never start it):
|
||||
| Job | Runs on | What | Time |
|
||||
| --- | --- | --- | --- |
|
||||
| `verify` | GitHub `ubuntu-latest` | Public-tree check, Python tests, rustfmt, clippy with warnings denied, Rust tests. Dependencies and the workspace build come from a cache saved by main's runs | ~4 min cold, less warm |
|
||||
| `parity / parity` | The Mac mini's runner (`ludovics-mac-mini`, label `horizon-game`) | The vanilla harnesses the change can affect (`tools/parity.py --changed <base>`) | seconds when nothing is affected; minutes per affected harness |
|
||||
| `parity / parity` | The Mac mini's runner (`ludovics-mac-mini`, label `horizon-game`) | Only for a change to a harness, an oracle, a probe or the runner (or the `run-parity` label): the vanilla harnesses the change can affect (`tools/parity.py --changed <base>`). Other pull requests skip it: [parity-main](#parity-on-main) checks them on main | seconds when nothing is affected; minutes per affected harness |
|
||||
|
||||
Pushes to main run `verify` only; the nightly audit covers main's harnesses.
|
||||
|
||||
@@ -39,10 +39,17 @@ copy is: the Mac mini. Its runner runs as the maintainer's account and keeps its
|
||||
depend only on the Minecraft version, are kept once in `shared-local/sounds/<version>/` (copied from a folder
|
||||
`horizon-prepare sounds` made from the maintainer's own game; the Mac mini has no official launcher of its own)
|
||||
and cloned into each preparation (not linked: vanilla's packs refuse symbolic links), for the audio harnesses.
|
||||
`tools/prepare_output.py check` then compares what was prepared with `crates/horizon-prepare/output-versions.txt`:
|
||||
launchers keep a player's prepared resources across releases whose preparer declares the same `OUTPUT_VERSION`
|
||||
(`crates/horizon-prepare/src/lib.rs`), so a change to the prepared files fails here until it bumps the number
|
||||
and appends the new digest (the error gives both lines).
|
||||
3. `tools/parity.py --changed <base sha>` picks the harnesses whose traced dependencies (their script, Python
|
||||
modules, Java oracles and every Rust source their probes compile) changed since the merge base, plus any
|
||||
harness nothing is recorded about yet, and runs those that have no pass for their current inputs. A change to
|
||||
`Cargo.lock` or `research-lock.json` affects every harness. Each harness has 25 minutes, the job 45.
|
||||
The selection runs with main's `tools/parity.py` and `tools/parity_reach.py` (`--changed <base> --list`), so a
|
||||
branch made before a better selection landed gets it too, and the head's own `parity.py` then runs the named
|
||||
harnesses. A branch that changes either file selects with its own code.
|
||||
|
||||
Failed harnesses' logs are uploaded as the `parity-logs` artifact.
|
||||
|
||||
@@ -80,10 +87,34 @@ gh api -X POST repos/ludovic111/horizon/branches/main/protection/required_status
|
||||
A skipped `parity` (a fork's pull request without the label) counts as passed, so requiring it never blocks a
|
||||
fork's pull request; the maintainer decides whether to run it.
|
||||
|
||||
## Parity on main
|
||||
|
||||
Most pull requests merge on `verify` alone and leave the vanilla harnesses to `.github/workflows/parity-main.yml`,
|
||||
which runs them on main a batch of merges at a time, on the Mac mini's runner:
|
||||
|
||||
- It starts on every push to main, one run at a time (`concurrency: parity-main`). Merges that land while a run
|
||||
goes on wait in one pending run: GitHub keeps only the newest and cancels the others, so the next run takes them
|
||||
all.
|
||||
- `tools/parity_main.py base` gives the newest commit main contains whose parity-main passed (else the last
|
||||
merge's parent), and the run is `tools/parity.py --changed <that commit>`: the union of the harnesses every merge
|
||||
since then can affect, once, with every earlier pass reused.
|
||||
- On a failure, `tools/parity_main.py bisect` checks out the batch's first-parent merges by halves and runs only
|
||||
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.
|
||||
|
||||
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
|
||||
(`tools/parity*.py`, `tools/lab.py`, `tools/parity_hosts.json`, `parity*.yml`), or carries the `run-parity` label
|
||||
(`run-parity.yml` runs it when the label is added).
|
||||
|
||||
## Game releases
|
||||
|
||||
`.github/workflows/release.yml` releases the game by itself. It starts after every green `checks` run of a push
|
||||
to main, every 10 minutes (so a release waiting for merges to settle goes out without another merge), and by hand.
|
||||
`.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.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
|
||||
+174
-67
@@ -552,7 +552,7 @@ pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String>
|
||||
fs::write(target.join(COMPLETE), "").map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
reuse_prepared(&root, tag);
|
||||
complete_packs(tag);
|
||||
complete_assets(tag);
|
||||
progress(1.0);
|
||||
Ok(())
|
||||
}
|
||||
@@ -774,13 +774,13 @@ fn reuse_prepared(versions: &Path, tag: &str) -> Option<String> {
|
||||
/// `reuse_prepared` for every installed version that is not prepared, oldest first, so a
|
||||
/// version prepared this way can serve the next. Launchers before 0.1.4 installed new versions
|
||||
/// without it; this catches those up. Returns each (version, where its resources came from).
|
||||
/// Prepared versions also get the run-time reports (`complete_reports`) and built-in packs
|
||||
/// (`complete_packs`) they lack.
|
||||
/// Prepared versions also get the run-time reports (`complete_reports`) and the built-in packs,
|
||||
/// panorama and sounds (`complete_assets`) they lack.
|
||||
pub fn reuse_prepared_everywhere() -> Vec<(String, String)> {
|
||||
let versions = versions_dir();
|
||||
let reused = reuse_prepared_in(&versions);
|
||||
for tag in installed_in(&versions) {
|
||||
complete_packs(&tag);
|
||||
complete_assets(&tag);
|
||||
}
|
||||
reused
|
||||
}
|
||||
@@ -856,19 +856,77 @@ fn complete_reports(versions: &Path, tag: &str) -> bool {
|
||||
complete
|
||||
}
|
||||
|
||||
/// Adds vanilla's built-in Programmer Art and High Contrast packs to a prepared version that
|
||||
/// lacks them, as `horizon-prepare packs` does: preparers before #1538 left them out, and a new
|
||||
/// version reuses the resources of the one before (`reuse_prepared`). They come from the player's
|
||||
/// own Minecraft asset store; without them there, one line in the log, once per launcher run, and
|
||||
/// the version plays as before. Nothing is prepared again.
|
||||
pub fn complete_packs(tag: &str) {
|
||||
complete_packs_in(&versions_dir(), tag, minecraft_folders, &|line| {
|
||||
/// What a prepared version may lack that the launcher adds without preparing again, each copied
|
||||
/// from the player's own Minecraft asset store as `horizon-prepare <kind>` does: what the
|
||||
/// player misses without it, whether the resources lack it, and the copy.
|
||||
type Completion = (
|
||||
&'static str,
|
||||
fn(&Path) -> bool,
|
||||
fn(&Path, Option<&Path>, horizon_prepare::Log) -> Result<PathBuf, String>,
|
||||
);
|
||||
|
||||
const COMPLETIONS: [Completion; 5] = [
|
||||
// Preparers before #1538 left the built-in packs out.
|
||||
(
|
||||
"Programmer Art and High Contrast",
|
||||
lacks_packs,
|
||||
horizon_prepare::packs,
|
||||
),
|
||||
// A preparation that could not read the asset store keeps the client jar's 1x1 grey
|
||||
// placeholders, its English alone, and no sounds (#1867: Prism Launcher installs prepared
|
||||
// since 0.2.48).
|
||||
(
|
||||
"The title panorama",
|
||||
lacks_panorama,
|
||||
horizon_prepare::panorama,
|
||||
),
|
||||
("The Unifont glyphs", lacks_unifont, horizon_prepare::fonts),
|
||||
("The languages", lacks_languages, horizon_prepare::languages),
|
||||
(
|
||||
"The sounds",
|
||||
|resources| !sounds_in(resources),
|
||||
horizon_prepare::sounds,
|
||||
),
|
||||
];
|
||||
|
||||
fn lacks_unifont(resources: &Path) -> bool {
|
||||
!resources
|
||||
.join("assets/minecraft/font/unifont.zip")
|
||||
.is_file()
|
||||
}
|
||||
|
||||
/// Whether `en_us`, the one language the client jar has, is the only one.
|
||||
fn lacks_languages(resources: &Path) -> bool {
|
||||
fs::read_dir(resources.join("assets/minecraft/lang")).map_or(true, |dir| dir.count() <= 1)
|
||||
}
|
||||
|
||||
fn lacks_packs(resources: &Path) -> bool {
|
||||
horizon_prepare::BUILT_IN_PACKS
|
||||
.iter()
|
||||
.any(|pack| !resources.join("assets").join(pack).is_file())
|
||||
}
|
||||
|
||||
/// Whether the first panorama face is missing or the client jar's 1x1 placeholder (a PNG's
|
||||
/// width is the big-endian word at byte 16, in the IHDR chunk).
|
||||
fn lacks_panorama(resources: &Path) -> bool {
|
||||
let face = resources.join("assets/minecraft/textures/gui/title/background/panorama_0.png");
|
||||
fs::read(face).map_or(true, |png| {
|
||||
png.get(16..20).is_none_or(|width| width == [0, 0, 0, 1])
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds what a prepared version lacks of `COMPLETIONS`: a new version reuses the resources of the
|
||||
/// one before (`reuse_prepared`), which may come from a preparer that left them out. Without them
|
||||
/// in any Minecraft folder, one line each in the log, once per launcher run, and the version plays
|
||||
/// as before. Nothing is prepared again.
|
||||
pub fn complete_assets(tag: &str) {
|
||||
complete_assets_in(&versions_dir(), tag, minecraft_folders, &|line| {
|
||||
eprintln!("{line}")
|
||||
});
|
||||
}
|
||||
|
||||
/// `folders`: where to look, asked only when the version lacks a pack.
|
||||
fn complete_packs_in(
|
||||
/// `folders`: where to look, asked only when the version lacks something.
|
||||
fn complete_assets_in(
|
||||
versions: &Path,
|
||||
tag: &str,
|
||||
folders: impl Fn() -> Vec<PathBuf>,
|
||||
@@ -879,37 +937,41 @@ fn complete_packs_in(
|
||||
static TRIED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
let root = versions.join(tag);
|
||||
let resources = resources_at(&root);
|
||||
let lacking = || {
|
||||
resources.join(RESOURCES_MANIFEST).exists()
|
||||
&& horizon_prepare::BUILT_IN_PACKS
|
||||
.iter()
|
||||
.any(|pack| !resources.join("assets").join(pack).is_file())
|
||||
let lacking = || -> Vec<&Completion> {
|
||||
if !resources.join(RESOURCES_MANIFEST).exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
COMPLETIONS
|
||||
.iter()
|
||||
.filter(|(_, lacks, _)| lacks(&resources))
|
||||
.collect()
|
||||
};
|
||||
if !lacking() {
|
||||
if lacking().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut tried = TRIED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if tried.contains(&root) || !lacking() {
|
||||
let lacking = lacking();
|
||||
if tried.contains(&root) || lacking.is_empty() {
|
||||
return;
|
||||
}
|
||||
tried.push(root.clone());
|
||||
let mut first_error = None;
|
||||
for folder in folders() {
|
||||
let copied = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
horizon_prepare::packs(&root, Some(&folder), &|line| log(line.to_string()))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the copy stopped unexpectedly".into()));
|
||||
match copied {
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
first_error.get_or_insert(e);
|
||||
}
|
||||
let folders = folders();
|
||||
for (what, _, copy) in lacking {
|
||||
let mut first_error = None;
|
||||
let added = folders.iter().any(|folder| {
|
||||
let copied = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
copy(&root, Some(folder), &|line| log(line.to_string()))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the copy stopped unexpectedly".into()));
|
||||
copied.map_err(|e| first_error.get_or_insert(e)).is_ok()
|
||||
});
|
||||
if !added {
|
||||
log(format!(
|
||||
"{tag}: {what} not added: {}",
|
||||
first_error.unwrap_or_else(|| "no Minecraft folder found".into())
|
||||
));
|
||||
}
|
||||
}
|
||||
log(format!(
|
||||
"{tag}: Programmer Art and High Contrast not added: {}",
|
||||
first_error.unwrap_or_else(|| "no Minecraft folder found".into())
|
||||
));
|
||||
}
|
||||
|
||||
/// The Minecraft folders whose asset store can hold what a prepared version lacks: the one
|
||||
@@ -1667,7 +1729,7 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
|
||||
/// The client's command, once its resources are prepared.
|
||||
pub fn client(tag: &str) -> Result<Command, String> {
|
||||
need_prepared(tag)?;
|
||||
complete_packs(tag);
|
||||
complete_assets(tag);
|
||||
crate::browser::horizon::before_play(&game_home());
|
||||
let mut cmd = client_command(tag);
|
||||
// A sign-in that can't be renewed (no network, expired) still lets the game start, offline.
|
||||
@@ -3148,7 +3210,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_versions_get_the_built_in_packs_without_preparing_again() {
|
||||
fn prepared_versions_get_what_the_asset_store_adds_without_preparing_again() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-launcher-packs-test-{}",
|
||||
std::process::id()
|
||||
@@ -3159,17 +3221,42 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, data).unwrap();
|
||||
};
|
||||
// An official launcher folder whose asset store holds the two packs.
|
||||
let packs = horizon_prepare::BUILT_IN_PACKS.map(|name| (name, name.as_bytes()));
|
||||
// An official launcher folder whose asset store holds the two packs, a panorama (fake
|
||||
// 2x2 PNG headers), Unifont, a language and the sound list.
|
||||
let background = "minecraft/textures/gui/title/background";
|
||||
let png = |n: u8| {
|
||||
let mut data = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR\0\0\0\x02\0\0\0\x02".to_vec();
|
||||
data.push(n);
|
||||
data
|
||||
};
|
||||
let mut assets: Vec<(String, Vec<u8>)> = horizon_prepare::BUILT_IN_PACKS
|
||||
.iter()
|
||||
.map(|name| (name.to_string(), name.as_bytes().to_vec()))
|
||||
.collect();
|
||||
for face in ["0", "1", "2", "3", "4", "5", "overlay"] {
|
||||
assets.push((
|
||||
format!("{background}/panorama_{face}.png"),
|
||||
png(face.as_bytes()[0]),
|
||||
));
|
||||
}
|
||||
for name in [
|
||||
"minecraft/font/include/unifont.json",
|
||||
"minecraft/font/unifont.zip",
|
||||
"pack.mcmeta",
|
||||
"minecraft/lang/fr_fr.json",
|
||||
"minecraft/sounds.json",
|
||||
] {
|
||||
assets.push((name.into(), format!("{{\"{name}\": 1}}").into_bytes()));
|
||||
}
|
||||
let mut index = serde_json::Map::new();
|
||||
for (name, data) in packs {
|
||||
for (name, data) in &assets {
|
||||
let hash = sources::sha1_hex(data);
|
||||
write(
|
||||
store.join("assets/objects").join(&hash[..2]).join(&hash),
|
||||
data,
|
||||
);
|
||||
index.insert(
|
||||
name.into(),
|
||||
name.clone(),
|
||||
serde_json::json!({"hash": hash, "size": data.len()}),
|
||||
);
|
||||
}
|
||||
@@ -3184,6 +3271,8 @@ mod tests {
|
||||
.to_string();
|
||||
write(store.join("versions/9.9/9.9.json"), manifest.as_bytes());
|
||||
fs::create_dir_all(&empty).unwrap();
|
||||
// The client jar's 1x1 placeholder.
|
||||
let placeholder = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR\0\0\0\x01\0\0\0\x01";
|
||||
let release = |tag: &str, prepared: bool| {
|
||||
let root = versions.join(tag);
|
||||
write(root.join(COMPLETE), b"");
|
||||
@@ -3191,27 +3280,27 @@ mod tests {
|
||||
"client_sha1": "c", "server_sha1": "s", "manifest_sha1": sources::sha1_hex(manifest.as_bytes())}});
|
||||
write(root.join("research-lock.json"), lock.to_string().as_bytes());
|
||||
if prepared {
|
||||
write(resources_at(&root).join(RESOURCES_MANIFEST), b"{}");
|
||||
let resources = resources_at(&root);
|
||||
write(resources.join(RESOURCES_MANIFEST), b"{}");
|
||||
write(resources.join("assets/minecraft/lang/en_us.json"), b"{}");
|
||||
write(
|
||||
resources_at(&root).join("assets/minecraft/lang/en_us.json"),
|
||||
b"{}",
|
||||
resources
|
||||
.join("assets")
|
||||
.join(background)
|
||||
.join("panorama_0.png"),
|
||||
placeholder,
|
||||
);
|
||||
}
|
||||
};
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let log = |line: String| lines.borrow_mut().push(line);
|
||||
let pack = |tag: &str, i: usize| {
|
||||
fs::read(
|
||||
resources_at(&versions.join(tag))
|
||||
.join("assets")
|
||||
.join(packs[i].0),
|
||||
)
|
||||
.ok()
|
||||
let asset = |tag: &str, name: &str| {
|
||||
fs::read(resources_at(&versions.join(tag)).join("assets").join(name)).ok()
|
||||
};
|
||||
|
||||
// Resources prepared before the packs were: a folder without an asset store is skipped.
|
||||
// Resources prepared without any of them: a folder without an asset store is skipped.
|
||||
release("v0.2.1", true);
|
||||
complete_packs_in(
|
||||
complete_assets_in(
|
||||
&versions,
|
||||
"v0.2.1",
|
||||
|| vec![empty.clone(), store.clone()],
|
||||
@@ -3219,37 +3308,55 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
*lines.borrow(),
|
||||
["Built-in pack assets ready: 2 files, 0.0 MB"]
|
||||
[
|
||||
"Built-in pack assets ready: 2 files, 0.0 MB",
|
||||
"Panorama assets ready: 7 files, 0.0 MB",
|
||||
"Font assets ready: 2 files, 0.0 MB",
|
||||
"Language assets ready: 2 files, 0.0 MB",
|
||||
"Sound assets ready: 1 files, 0.0 MB",
|
||||
]
|
||||
);
|
||||
for (i, (_, data)) in packs.iter().enumerate() {
|
||||
assert_eq!(pack("v0.2.1", i).as_deref(), Some(*data));
|
||||
for (name, data) in &assets {
|
||||
assert_eq!(asset("v0.2.1", name).as_ref(), Some(data), "{name}");
|
||||
}
|
||||
// Nothing else is prepared again.
|
||||
let resources = resources_at(&versions.join("v0.2.1"));
|
||||
assert_eq!(fs::read(resources.join(RESOURCES_MANIFEST)).unwrap(), b"{}");
|
||||
assert!(!resources.join("assets/minecraft/sounds.json").exists());
|
||||
|
||||
// No asset store anywhere: one line, the version stays prepared, and no second try.
|
||||
// No asset store anywhere: one line each, the version stays prepared, and no second try.
|
||||
lines.borrow_mut().clear();
|
||||
release("v0.2.2", true);
|
||||
complete_packs_in(&versions, "v0.2.2", || vec![empty.clone()], &log);
|
||||
complete_packs_in(&versions, "v0.2.2", || vec![empty.clone()], &log);
|
||||
assert_eq!(lines.borrow().len(), 1);
|
||||
assert!(
|
||||
lines.borrow()[0].starts_with("v0.2.2: Programmer Art and High Contrast not added: "),
|
||||
"{lines:?}"
|
||||
complete_assets_in(&versions, "v0.2.2", || vec![empty.clone()], &log);
|
||||
complete_assets_in(&versions, "v0.2.2", || vec![empty.clone()], &log);
|
||||
let what = lines
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|line| line.split(" not added: ").next().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
what,
|
||||
[
|
||||
"v0.2.2: Programmer Art and High Contrast",
|
||||
"v0.2.2: The title panorama",
|
||||
"v0.2.2: The Unifont glyphs",
|
||||
"v0.2.2: The languages",
|
||||
"v0.2.2: The sounds",
|
||||
]
|
||||
);
|
||||
assert!(resources_at(&versions.join("v0.2.2"))
|
||||
.join(RESOURCES_MANIFEST)
|
||||
.exists());
|
||||
assert_eq!(pack("v0.2.2", 0), None);
|
||||
assert_eq!(
|
||||
asset("v0.2.2", &format!("{background}/panorama_0.png")).as_deref(),
|
||||
Some(&placeholder[..])
|
||||
);
|
||||
|
||||
// Not prepared, or complete: nothing to do, without even looking for a Minecraft folder.
|
||||
lines.borrow_mut().clear();
|
||||
release("v0.2.3", false);
|
||||
let unasked = || -> Vec<PathBuf> { unreachable!("no folder is needed") };
|
||||
complete_packs_in(&versions, "v0.2.3", unasked, &log);
|
||||
complete_packs_in(&versions, "v0.2.1", unasked, &log);
|
||||
complete_assets_in(&versions, "v0.2.3", unasked, &log);
|
||||
complete_assets_in(&versions, "v0.2.1", unasked, &log);
|
||||
assert!(lines.borrow().is_empty());
|
||||
assert!(!resources_at(&versions.join("v0.2.3")).exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -831,13 +831,14 @@ impl Launcher {
|
||||
// A little earlier than the spawn: file times can be coarse.
|
||||
self.client_started =
|
||||
Some(std::time::SystemTime::now() - std::time::Duration::from_secs(2));
|
||||
// The built-in packs the resources lack and the session (a network round trip when the
|
||||
// token needs renewing) come first; `drain` starts the game when the session arrives.
|
||||
// What the resources lack (built-in packs, panorama, sounds) and the session (a network
|
||||
// round trip when the token needs renewing) come first; `drain` starts the game when the
|
||||
// session arrives.
|
||||
self.starting_client = Some(tag.clone());
|
||||
self.play_pressed = Some(Instant::now());
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
backend::complete_packs(&tag);
|
||||
backend::complete_assets(&tag);
|
||||
let session = (|| {
|
||||
let mut accounts = Accounts::load();
|
||||
let Some((a, secrets)) = account::session_for_launch(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.ItemLore;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
|
||||
/**
|
||||
* The styled lines {@code ItemLore.addToTooltip} adds, one stack (SNBT) per input
|
||||
* line. Each line is its visited spans ({@code text, colour, bold, italic,
|
||||
* underlined, strikethrough, obfuscated}); lines are separated by {@code ||}.
|
||||
*/
|
||||
public final class VanillaLoreTooltipOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
var context = Item.TooltipContext.of(ParityData.lookup);
|
||||
var output = new ArrayList<String>();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
if (line.isBlank() || line.startsWith("#")) continue;
|
||||
var stack = ParityData.stack(line);
|
||||
var display = stack.getOrDefault(DataComponents.TOOLTIP_DISPLAY, TooltipDisplay.DEFAULT);
|
||||
ItemLore lore = stack.get(DataComponents.LORE);
|
||||
if (lore == null || !display.shows(DataComponents.LORE)) {
|
||||
output.add("-");
|
||||
continue;
|
||||
}
|
||||
var lines = new ArrayList<String>();
|
||||
lore.addToTooltip(context, component -> lines.add(spans(component)), TooltipFlag.NORMAL, stack);
|
||||
output.add(lines.isEmpty() ? "-" : String.join(" || ", lines));
|
||||
}
|
||||
Files.write(Path.of(args[1]), output);
|
||||
}
|
||||
|
||||
/** {@code Component.visit} from {@code Style.EMPTY}: the style a tooltip draws. */
|
||||
static String spans(Component component) {
|
||||
var out = new StringBuilder();
|
||||
component.visit((style, text) -> {
|
||||
if (text.isEmpty()) return Optional.empty();
|
||||
if (out.length() > 0) out.append(" | ");
|
||||
int color = style.getColor() == null ? -1 : style.getColor().getValue();
|
||||
out.append(text).append('\t').append(color).append('\t')
|
||||
.append(style.isBold()).append('\t')
|
||||
.append(style.isItalic()).append('\t')
|
||||
.append(style.isUnderlined()).append('\t')
|
||||
.append(style.isStrikethrough()).append('\t')
|
||||
.append(style.isObfuscated());
|
||||
return Optional.empty();
|
||||
}, Style.EMPTY);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
+43
-6
@@ -968,13 +968,15 @@ links = ["#1709"]
|
||||
[[item]]
|
||||
id = "opengl-fallback"
|
||||
title = "OpenGL for old computers"
|
||||
status = "later"
|
||||
status = "shipped"
|
||||
shipped_in = "v0.2.63"
|
||||
area = "platforms"
|
||||
plain = "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL."
|
||||
dev = """Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL \
|
||||
backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like \
|
||||
the DirectX 12 fallback that already ships."""
|
||||
links = ["docs/graphics.md"]
|
||||
plain = """Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL \
|
||||
by itself, and Options > Graphics can pick OpenGL by hand."""
|
||||
dev = """wgpu's GL backend (WGL on Windows, EGL on Linux) as the last fallback after Vulkan and DirectX 12 (#1917), \
|
||||
sprite arrays and CPU sprite animation for drivers without compute (#1920), no indirect draws below GL 4.3 \
|
||||
(#1936). Minimum OpenGL 3.3 core. Shader packs don't run on it yet."""
|
||||
links = ["#1917", "#1920", "#1936"]
|
||||
|
||||
[[item]]
|
||||
id = "phones-and-browser"
|
||||
@@ -1187,3 +1189,38 @@ plain = """The launcher's Servers page creates, starts, backs up and updates a H
|
||||
invites friends."""
|
||||
dev = """The launcher's Servers page (#1026) over `horizon-server` (docs/hosting.md)."""
|
||||
links = ["#1026", "docs/hosting.md"]
|
||||
|
||||
[[item]]
|
||||
id = "cli-server-install"
|
||||
title = "A server you install with one command"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for \
|
||||
VPSes and headless machines."""
|
||||
dev = """A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script \
|
||||
(Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the \
|
||||
player's own game files and starts the server, documented in docs/hosting.md."""
|
||||
links = ["docs/hosting.md"]
|
||||
|
||||
[[item]]
|
||||
id = "launcher-look-menus"
|
||||
title = "Game menus in the launcher's style"
|
||||
status = "later"
|
||||
area = "extras"
|
||||
plain = """An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: \
|
||||
the vanilla menus stay as they are."""
|
||||
dev = """A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, \
|
||||
pause and options screens with the launcher's design tokens (crates/horizon-theme), following \
|
||||
docs/design-system.md; off by default because it changes the vanilla look."""
|
||||
links = ["docs/design-system.md"]
|
||||
|
||||
[[item]]
|
||||
id = "linux-packages"
|
||||
title = "AppImage and a Nix flake"
|
||||
status = "later"
|
||||
area = "platforms"
|
||||
plain = """Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake."""
|
||||
dev = """A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a \
|
||||
flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), \
|
||||
the way horizon-parity's NixOS setup runs them today."""
|
||||
links = []
|
||||
@@ -0,0 +1,43 @@
|
||||
"""tools/parity_main.py: the base of a parity-main run, and the bisect after a failure."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from tools import parity_main
|
||||
|
||||
|
||||
class ParityMainTests(unittest.TestCase):
|
||||
def test_first_bad_finds_the_first_failing_merge(self):
|
||||
commits = list("abcdefgh") # h failed; the base before a passed
|
||||
for culprit in commits:
|
||||
tried = []
|
||||
fails = lambda commit: tried.append(commit) or commit >= culprit
|
||||
self.assertEqual(parity_main.first_bad(commits, fails), culprit)
|
||||
self.assertLessEqual(len(tried), 3, "log2 of 8 merges")
|
||||
self.assertEqual(parity_main.first_bad(["a"], lambda commit: self.fail("nothing to try")), "a")
|
||||
|
||||
def test_base_is_the_newest_green_run_head_contains(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(parity_main, "ROOT", Path(tmp)):
|
||||
git = lambda *arguments: parity_main.git(*arguments).stdout.strip()
|
||||
git("init", "-q")
|
||||
for n in range(3):
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", str(n))
|
||||
head, parent, grandparent = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^1"), git("rev-parse", "HEAD~2")
|
||||
git("checkout", "-q", "-b", "side", grandparent)
|
||||
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "side")
|
||||
side = git("rev-parse", "HEAD")
|
||||
git("checkout", "-q", head)
|
||||
self.assertEqual(parity_main.base(runs=[side, grandparent, parent]), grandparent,
|
||||
"a run on a commit HEAD doesn't contain is skipped")
|
||||
self.assertEqual(parity_main.base(runs=[]), parent, "no green run yet: the last merge")
|
||||
self.assertEqual(parity_main.base(runs=[head]), head)
|
||||
|
||||
def test_a_harness_a_commit_does_not_have_cannot_fail_there(self):
|
||||
with mock.patch.object(parity_main.subprocess, "run") as run:
|
||||
self.assertFalse(parity_main.run_harnesses(["not_a_harness_yet"], 60))
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""tools/parity_reach.py: which workspace sources a harness's probe can reach."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
from tools import parity, parity_reach
|
||||
|
||||
PROBE = [["example", "world_probe", "release", ["-p", "a"], True]]
|
||||
|
||||
|
||||
def workspace(root, files):
|
||||
for path, text in files.items():
|
||||
(root / path).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / path).write_text(textwrap.dedent(text), encoding="utf-8")
|
||||
|
||||
|
||||
class ReachTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.directory.name)
|
||||
workspace(self.root, {
|
||||
"crates/a/Cargo.toml": '[package]\nname = "a"\n\n[[example]]\nname = "world_probe"\npath = "examples/probe/main.rs"\n',
|
||||
"crates/a/src/lib.rs": """
|
||||
pub mod used;
|
||||
pub mod unused;
|
||||
mod methods;
|
||||
mod display;
|
||||
mod table;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
""",
|
||||
"crates/a/src/used.rs": """
|
||||
pub struct Thing;
|
||||
pub fn helper() -> u32 { 1 }
|
||||
""",
|
||||
# Same names as used.rs, but nothing names this module.
|
||||
"crates/a/src/unused.rs": "pub struct Thing;\npub fn helper() -> u32 { 2 }\n",
|
||||
"crates/a/src/methods.rs": """
|
||||
use crate::used::Thing;
|
||||
impl Thing {
|
||||
pub fn method(&self) -> u32 { 3 }
|
||||
pub fn never_called(&self) -> u32 { 4 }
|
||||
}
|
||||
""",
|
||||
"crates/a/src/display.rs": """
|
||||
impl std::fmt::Display for crate::used::Thing {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "thing") }
|
||||
}
|
||||
""",
|
||||
"crates/a/src/table.rs": """
|
||||
macro_rules! table { () => { pub struct Table; }; }
|
||||
table!();
|
||||
""",
|
||||
"crates/a/src/tests.rs": "use crate::used::Thing;\nimpl Thing { pub fn method_in_tests(&self) {} }\n",
|
||||
"crates/a/examples/probe/main.rs": "mod dispatch;\nmod p;\n",
|
||||
"crates/a/examples/probe/dispatch.rs": "pub fn args() {}\n",
|
||||
"crates/a/examples/probe/p.rs": """
|
||||
use a::used::{helper, Thing};
|
||||
pub fn main() { println!("{} {} {}", helper(), Thing.method(), Thing); }
|
||||
""",
|
||||
})
|
||||
self.dependencies = sorted(str(path.relative_to(self.root)) for path in self.root.rglob("*") if path.is_file())
|
||||
self.dependencies.remove("crates/a/examples/probe/main.rs") # parity.only_probes_run leaves it out
|
||||
|
||||
def tearDown(self):
|
||||
self.directory.cleanup()
|
||||
|
||||
def narrowed(self, changed=(), artifacts=PROBE):
|
||||
graph = parity_reach.SourceGraph(self.root, parity.rust_code)
|
||||
return parity_reach.narrow(self.dependencies, artifacts, graph, set(changed))
|
||||
|
||||
def test_keeps_what_the_probe_reaches(self):
|
||||
kept = {path for path, (whole, _) in self.narrowed().items() if whole}
|
||||
self.assertIn("crates/a/src/used.rs", kept)
|
||||
self.assertIn("crates/a/src/methods.rs", kept) # Thing.method()
|
||||
self.assertIn("crates/a/src/display.rs", kept) # std's Display for a reached type
|
||||
self.assertIn("crates/a/Cargo.toml", kept)
|
||||
self.assertNotIn("crates/a/src/unused.rs", kept) # its names match, but nothing names its module
|
||||
self.assertNotIn("crates/a/src/table.rs", kept)
|
||||
self.assertNotIn("crates/a/src/tests.rs", kept) # compiled into tests only
|
||||
|
||||
def test_module_parents_count_through_their_declarations(self):
|
||||
self.assertEqual(self.narrowed()["crates/a/src/lib.rs"], (False, {"used", "methods", "display"}))
|
||||
text = (self.root / "crates/a/src/lib.rs").read_text()
|
||||
self.assertEqual(parity_reach.declarations(text, {"used"}, False), "pub mod used;")
|
||||
self.assertEqual(parity_reach.declarations(text, {"tests"}, False), "#[cfg(test)]\nmod tests;")
|
||||
# Adding or dropping a module beside the reached ones changes neither view.
|
||||
other = text.replace("mod table;", "mod table2;\nmod more;")
|
||||
self.assertEqual(parity_reach.declarations(other, {"used"}, False), "pub mod used;")
|
||||
self.assertEqual(parity_reach.declarations(other, {"used"}, True), parity_reach.declarations(text, {"used"}, True))
|
||||
self.assertNotEqual(parity_reach.declarations(text + "pub fn f() {}\n", {"used"}, True),
|
||||
parity_reach.declarations(text, {"used"}, True))
|
||||
|
||||
def test_a_new_file_counts_once_it_is_reached(self):
|
||||
(self.root / "crates/a/src/lib.rs").write_text("pub mod used;\nmod extra;\n")
|
||||
workspace(self.root, {"crates/a/src/extra.rs": "impl crate::used::Thing { pub fn method(&self) -> u32 { 5 } }\n"})
|
||||
self.assertIn("crates/a/src/extra.rs", self.narrowed(changed={"crates/a/src/extra.rs"}))
|
||||
|
||||
def test_a_deleted_dependency_counts(self):
|
||||
(self.root / "crates/a/src/unused.rs").unlink()
|
||||
self.assertIn("crates/a/src/unused.rs", self.narrowed())
|
||||
|
||||
def test_unknown_probes_keep_everything(self):
|
||||
self.assertEqual(set(self.narrowed(artifacts=[])), set(self.dependencies))
|
||||
self.assertEqual(set(self.narrowed(artifacts=[["example", "missing", "release", ["-p", "a"], True]])),
|
||||
set(self.dependencies))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -300,3 +300,17 @@ class LargeOutputTests(unittest.TestCase):
|
||||
self.assertFalse((own / "dump.txt").exists())
|
||||
# chunk_generation may still be writing there.
|
||||
self.assertTrue((shared / "dump.txt").exists())
|
||||
|
||||
def test_rust_named_directories_and_big_oracle_outputs_go_too(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
local, state = Path(tmp) / ".local", Path(tmp) / "state"
|
||||
own = local / "rust-state-provider-parity"
|
||||
own.mkdir(parents=True)
|
||||
for name, size in (("rust.txt", 16), ("server.txt", 16), ("client.txt", 64), ("report.json", 4)):
|
||||
(own / name).write_bytes(b"x" * size)
|
||||
trace = {"commands": [["java", "Oracle", str(own)], ["probe", "fixture", str(own)]]}
|
||||
with mock.patch.multiple(parity, LOCAL=local, STATE_DIR=state, LARGE_OUTPUT=8, KEPT_ORACLE_OUTPUT=32):
|
||||
listing = parity.outputs_listing("state_provider#2", fresh=True)
|
||||
listing.write_text(f"{own / 'server.txt'}\n{own / 'client.txt'}\n")
|
||||
parity.clear_large_outputs("state_provider#2", trace)
|
||||
self.assertEqual(sorted(path.name for path in own.iterdir()), ["report.json", "server.txt"])
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tools import prepare_output
|
||||
|
||||
|
||||
class PrepareOutputTests(unittest.TestCase):
|
||||
def preparation(self, root: Path, files: dict[str, bytes]) -> Path:
|
||||
for rel, data in files.items():
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return root
|
||||
|
||||
def test_what_varies_between_runs_does_not_count(self):
|
||||
manifest = {'schema': 7, 'data_version': 5023, 'render_files_sha256': {'a.json': 'x'}}
|
||||
a = {
|
||||
'horizon-resources.json': json.dumps(manifest).encode(),
|
||||
'render-definitions.json': b'{"a": 1, "b": [1, 2]}',
|
||||
'creative-inventory.json': b'{"tags": [["b", "a"]]}',
|
||||
'block-behaviours.json': b'{"c": "Foo$$Lambda/0x0000012a"}',
|
||||
'export.log': b'run 1',
|
||||
'assets/minecraft/sounds/a.ogg': b'one',
|
||||
}
|
||||
manifest['render_files_sha256'] = {'a.json': 'y'}
|
||||
b = {
|
||||
'horizon-resources.json': json.dumps(manifest).encode(),
|
||||
'render-definitions.json': b'{"b": [1, 2], "a": 1}',
|
||||
'creative-inventory.json': b'{"tags": [["a", "b"]]}',
|
||||
'block-behaviours.json': b'{"c": "Foo$$Lambda/0x00000ff0"}',
|
||||
'export.log': b'run 2',
|
||||
'assets/minecraft/sounds/a.ogg': b'two',
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
first = prepare_output.digest(self.preparation(Path(tmp) / 'a', a))
|
||||
second = prepare_output.digest(self.preparation(Path(tmp) / 'b', b))
|
||||
self.assertEqual(first, second)
|
||||
# The data does, and so do the manifest's schema and the files there are.
|
||||
(Path(tmp) / 'b/render-definitions.json').write_bytes(b'{"b": [2, 1], "a": 1}')
|
||||
self.assertNotEqual(first, prepare_output.digest(Path(tmp) / 'b'))
|
||||
(Path(tmp) / 'b/render-definitions.json').write_bytes(b'{"b": [1, 2], "a": 1}')
|
||||
(Path(tmp) / 'b/horizon-resources.json').write_text(json.dumps({**manifest, 'schema': 8}))
|
||||
self.assertNotEqual(first, prepare_output.digest(Path(tmp) / 'b'))
|
||||
(Path(tmp) / 'b/horizon-resources.json').write_text(json.dumps(manifest))
|
||||
(Path(tmp) / 'b/new.nbt').write_bytes(b'')
|
||||
self.assertNotEqual(first, prepare_output.digest(Path(tmp) / 'b'))
|
||||
|
||||
def test_the_versions_file_ends_at_the_constant(self):
|
||||
versions = prepare_output.parse_versions('# comment\n1 aa\n2 bb\n')
|
||||
self.assertEqual(versions, [(1, 'aa'), (2, 'bb')])
|
||||
self.assertEqual(prepare_output.problems(versions, 2), [])
|
||||
self.assertTrue(prepare_output.problems(versions, 1))
|
||||
self.assertTrue(prepare_output.problems([(1, 'aa'), (3, 'bb')], 3))
|
||||
self.assertTrue(prepare_output.problems([(1, 'aa'), (2, 'aa')], 2))
|
||||
root = Path(prepare_output.__file__).resolve().parent.parent
|
||||
current = prepare_output.parse_versions((root / prepare_output.VERSIONS).read_text())
|
||||
constant = prepare_output.CONSTANT.search((root / prepare_output.LIB).read_text())
|
||||
self.assertEqual(prepare_output.problems(current, int(constant.group(1))), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@ import datetime as dt
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from tools import release_train as train
|
||||
|
||||
@@ -147,6 +148,26 @@ class ReleaseTrainTests(unittest.TestCase):
|
||||
self.assertFalse(train.decision("game", [], NOW, new_line="0.3", blockers=[7])[0])
|
||||
self.assertTrue(train.decision("game", [pr(1)], NOW, blockers=[])[0])
|
||||
|
||||
def test_game_releases_ship_only_what_parity_main_passed(self):
|
||||
def runs(parity, checks):
|
||||
def gh_json(*arguments):
|
||||
table = parity if "parity-main" in arguments else checks
|
||||
if "--commit" in arguments:
|
||||
sha = arguments[arguments.index("--commit") + 1]
|
||||
return [{"status": s, "conclusion": c} for h, s, c in table if h == sha]
|
||||
return [{"headSha": h, "status": s, "conclusion": c} for h, s, c in table]
|
||||
return mock.patch.object(train, "gh_json", gh_json)
|
||||
checks = [("c", "in_progress", ""), ("b", "completed", "success"), ("a", "completed", "success")]
|
||||
with runs([("c", "queued", ""), ("b", "completed", "cancelled"), ("a", "completed", "success")], checks):
|
||||
self.assertEqual(train.green_main_head(), "b", "checks alone: the newest green checks")
|
||||
self.assertEqual(train.green_main_head(parity=True), "a", "a superseded parity-main run counts for nothing")
|
||||
self.assertTrue(train.passed_on("parity-main", "a"))
|
||||
self.assertFalse(train.passed_on("parity-main", "b"))
|
||||
with runs([("b", "completed", "failure"), ("a", "completed", "success")], checks):
|
||||
self.assertIsNone(train.green_main_head(parity=True), "a failed batch holds releases until fixed")
|
||||
with runs([("c", "completed", "success")], checks):
|
||||
self.assertIsNone(train.green_main_head(parity=True), "its checks must pass too")
|
||||
|
||||
def test_a_release_is_published_only_after_its_smoke_test_and_with_no_blocker(self):
|
||||
drew = {"error": None, "frames": 12710, "chunks": 81, "meshed_sections": 550, "player": [160.5, 71.0, 156.5]}
|
||||
self.assertIsNone(train.gate(drew, []))
|
||||
|
||||
+56
-13
@@ -45,6 +45,11 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
from . import parity_reach
|
||||
except ImportError:
|
||||
import parity_reach
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOCAL = ROOT / ".local"
|
||||
STATE_DIR = LOCAL / "parity-runner"
|
||||
@@ -652,6 +657,9 @@ def prebuild(artifacts, jobs):
|
||||
|
||||
|
||||
LARGE_OUTPUT = 64 << 20
|
||||
# Vanilla outputs above this are deleted after a pass too: rerunning those few oracles (12 harnesses on the Mac
|
||||
# mini's runner) costs less than the 26 GB they held in every checkout; smaller ones stay for oracle reuse.
|
||||
KEPT_ORACLE_OUTPUT = 256 << 20
|
||||
OWN_DIRECTORIES = {"parity-target", "parity-runner", "rea", "logs", "gate"}
|
||||
|
||||
|
||||
@@ -667,11 +675,12 @@ def clear_large_outputs(name, trace):
|
||||
"""After a pass, delete the large files a harness regenerates on every run.
|
||||
|
||||
Harness outputs reach gigabytes and used to stay in every worktree. Vanilla
|
||||
oracle outputs are kept, since reusing an oracle run needs them in place;
|
||||
everything else large under the harness's .local directories is rebuilt on
|
||||
its next run."""
|
||||
oracle outputs under KEPT_ORACLE_OUTPUT are kept, since reusing an oracle
|
||||
run needs them in place; everything else large under the harness's .local
|
||||
directories (`<name>...` or `rust-<name>-parity`) is rebuilt on its next run."""
|
||||
local = LOCAL.resolve()
|
||||
keep = {os.path.realpath(line) for line in outputs_listing(name).read_text(encoding="utf-8").splitlines() if line}
|
||||
own = name.split("#")[0].replace("_", "-")
|
||||
directories = set()
|
||||
for command in trace["commands"]:
|
||||
for argument in command[1:]:
|
||||
@@ -683,12 +692,13 @@ def clear_large_outputs(name, trace):
|
||||
# Only the harness's own directory: another harness running beside it may be writing
|
||||
# large files in a directory this one merely read (decorated_region compiles its oracle
|
||||
# into chunk-generation-parity while chunk_generation writes its dumps there).
|
||||
if top not in OWN_DIRECTORIES and top.startswith(name.replace("_", "-")):
|
||||
if top not in OWN_DIRECTORIES and (top.startswith(own) or top == f"rust-{own}-parity"):
|
||||
directories.add(local / top)
|
||||
for directory in directories:
|
||||
for file in (directory.rglob("*") if directory.is_dir() else []):
|
||||
try:
|
||||
if file.is_file() and not file.is_symlink() and file.stat().st_size >= LARGE_OUTPUT and str(file) not in keep:
|
||||
size = file.stat().st_size if file.is_file() and not file.is_symlink() else 0
|
||||
if size >= LARGE_OUTPUT and (str(file) not in keep or size > KEPT_ORACLE_OUTPUT):
|
||||
file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -823,8 +833,11 @@ class LocalExecutor:
|
||||
environment.pop("PARITY_SHARD", None)
|
||||
if shard:
|
||||
environment["PARITY_SHARD"] = shard
|
||||
# stdin from /dev/null: tools/parity_shim.py never reuses an oracle run that reads a pipe, and CI runners
|
||||
# (and --worker's ssh) give the runner one, which kept oracle reuse off on CI.
|
||||
process = subprocess.Popen([sys.executable, str(Path(__file__)), "--trace", str(trace), str(script)],
|
||||
cwd=ROOT, env=environment, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
cwd=ROOT, env=environment, stdin=subprocess.DEVNULL, stdout=log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
self.running[name] = {"process": process, "started": time.monotonic(), "log": log, "expected": expected,
|
||||
"slot": slot, "script": script}
|
||||
return True
|
||||
@@ -1224,20 +1237,44 @@ def changed_harnesses(found, records, ref):
|
||||
return found_changed
|
||||
|
||||
|
||||
def affected_harnesses(found, records, changed):
|
||||
def affected_harnesses(found, records, changed, reach=True, base=None):
|
||||
"""Harnesses the changed files can affect: their script or a recorded dependency (here, or in the shared
|
||||
dependency lists) changed, a file every harness depends on changed, or nothing is recorded about them yet."""
|
||||
dependency lists) changed, a file every harness depends on changed, or nothing is recorded about them yet.
|
||||
With reach, a harness's workspace Rust sources count only if its probes can reach them (tools/parity_reach.py);
|
||||
a harness whose probes can't be placed keeps them all. A changed file counts unless, compared with base (a
|
||||
commit), only declarations of modules the harness doesn't reach changed (tools/parity_reach.py)."""
|
||||
if changed & SHARED_INPUTS:
|
||||
return set(found)
|
||||
graph = None
|
||||
if reach and any(path.startswith("crates/") for path in changed):
|
||||
cache_path = STATE_DIR / "reach-cache.json"
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
cache = {}
|
||||
graph = parity_reach.SourceGraph(ROOT, rust_code, cache)
|
||||
affected = set()
|
||||
for name, script in found.items():
|
||||
dependencies = records.get(name, {}).get("dependencies")
|
||||
record = records.get(name) or {}
|
||||
dependencies = record.get("dependencies")
|
||||
template = SHARED / "dependencies" / f"{name}.json"
|
||||
if dependencies is None and template.exists():
|
||||
dependencies = json.loads(template.read_text(encoding="utf-8")).get("dependencies")
|
||||
if dependencies is None or Path(script).relative_to(ROOT).as_posix() in changed or \
|
||||
any(path.split(KEEP)[0] in changed for path in dependencies):
|
||||
record = json.loads(template.read_text(encoding="utf-8"))
|
||||
dependencies = record.get("dependencies")
|
||||
if dependencies is None or Path(script).relative_to(ROOT).as_posix() in changed:
|
||||
affected.add(name)
|
||||
continue
|
||||
plain = {path.split(KEEP)[0] for path in dependencies}
|
||||
if graph is not None and plain & changed:
|
||||
narrowed = parity_reach.narrow(dependencies, recorded_artifacts(record), graph, changed)
|
||||
if any(path.split(KEEP)[0] in changed and (children is None or parity_reach.declarations_changed(
|
||||
ROOT, path.split(KEEP)[0], children, base, whole)) for path, (whole, children) in narrowed.items()):
|
||||
affected.add(name)
|
||||
elif plain & changed:
|
||||
affected.add(name)
|
||||
if graph is not None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(json.dumps(graph.saved_cache()), encoding="utf-8")
|
||||
return affected
|
||||
|
||||
|
||||
@@ -1280,6 +1317,8 @@ def main():
|
||||
parser.add_argument("--flake-rounds", type=int, default=3, help="runs required by --flake-check (default 3)")
|
||||
parser.add_argument("--changed", metavar="REF", help="only harnesses the changes since the merge base with REF can "
|
||||
"affect (and that have not passed with their current inputs): the pull-request check")
|
||||
parser.add_argument("--by-crate", action="store_true", help="with --changed: count every Rust source a probe "
|
||||
"links, not only those it can reach (tools/parity_reach.py)")
|
||||
parser.add_argument("--changed-files", metavar="FILE", help="like --changed, for the changed paths listed in FILE, "
|
||||
"one per line: for a copy without Git history (tools/pr_ready.py on another machine)")
|
||||
args = parser.parse_args()
|
||||
@@ -1316,7 +1355,11 @@ def main():
|
||||
changed, since = changed_files(args.changed), f" since the merge base with {args.changed}"
|
||||
if changed is None:
|
||||
parser.error(f"no merge base with {args.changed}")
|
||||
affected = affected_harnesses(found, records, changed)
|
||||
base = None
|
||||
if args.changed:
|
||||
merge_base = subprocess.run(["git", "merge-base", "HEAD", args.changed], cwd=ROOT, capture_output=True, text=True)
|
||||
base = merge_base.stdout.strip() or None
|
||||
affected = affected_harnesses(found, records, changed, reach=not args.by_crate, base=base)
|
||||
chosen = [name for name in found if name in affected and stale(name, state, hasher, environment)]
|
||||
print(f"{len(changed)} files changed{since}: {len(affected)} harnesses "
|
||||
f"depend on them, {len(chosen)} without a pass for their current inputs.", flush=True)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""parity-main (.github/workflows/parity-main.yml): the vanilla harnesses run on main, a batch of merges at a time.
|
||||
|
||||
python3 tools/parity_main.py base # the commit this run checks from: main's last green parity-main
|
||||
python3 tools/parity_main.py bisect BASE # after a failure: the first merge since BASE that broke a harness
|
||||
|
||||
`base` prints the newest commit on main whose parity-main run passed and that HEAD contains, else HEAD's first parent,
|
||||
so a run tests every merge since the last green one (`tools/parity.py --changed <base>`), however many landed while
|
||||
the previous run went on. `bisect` reads the harnesses that failed (.local/parity-runner/last-run.json), checks out
|
||||
the first-parent merges between BASE and HEAD by halves and runs only those harnesses, then names the first merge they
|
||||
fail on (written to $GITHUB_STEP_SUMMARY and, with --comment, on its pull request). HEAD is checked out again at the end.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO = os.environ.get("GITHUB_REPOSITORY", "ludovic111/horizon")
|
||||
LAST_RUN = ROOT / ".local" / "parity-runner" / "last-run.json"
|
||||
|
||||
|
||||
def git(*arguments, check=True):
|
||||
result = subprocess.run(["git", *arguments], cwd=ROOT, capture_output=True, text=True)
|
||||
if check and result.returncode:
|
||||
sys.exit(f"git {' '.join(arguments)}: {result.stderr.strip()}")
|
||||
return result
|
||||
|
||||
|
||||
def green_runs():
|
||||
"""Head commits of main's passing parity-main runs, newest first."""
|
||||
result = subprocess.run(["gh", "run", "list", "--repo", REPO, "--workflow", "parity-main", "--branch", "main",
|
||||
"--status", "success", "--limit", "30", "--json", "headSha"], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
print(f"note: no parity-main history ({result.stderr.strip()})", file=sys.stderr)
|
||||
return []
|
||||
return [run["headSha"] for run in json.loads(result.stdout or "[]")]
|
||||
|
||||
|
||||
def base(head="HEAD", runs=None):
|
||||
for sha in green_runs() if runs is None else runs:
|
||||
if git("merge-base", "--is-ancestor", sha, head, check=False).returncode == 0:
|
||||
return git("rev-parse", sha).stdout.strip()
|
||||
return git("rev-parse", f"{head}^1").stdout.strip()
|
||||
|
||||
|
||||
def first_bad(commits, fails):
|
||||
"""The first of commits (oldest first, the last known to fail; the one before them known to pass) where fails()."""
|
||||
good, bad = -1, len(commits) - 1
|
||||
while bad - good > 1:
|
||||
middle = (good + bad) // 2
|
||||
if fails(commits[middle]):
|
||||
bad = middle
|
||||
else:
|
||||
good = middle
|
||||
return commits[bad]
|
||||
|
||||
|
||||
def harness_exists(name):
|
||||
return (ROOT / "tools" / f"test_{name.split('#')[0]}_parity.py").exists()
|
||||
|
||||
|
||||
def run_harnesses(names, timeout):
|
||||
"""Whether any of the harnesses fails at the checked-out commit (those it doesn't have yet can't)."""
|
||||
present = [name for name in names if harness_exists(name)]
|
||||
if not present:
|
||||
return False
|
||||
return subprocess.run([sys.executable, "tools/parity.py", *present, "--timeout", str(timeout)], cwd=ROOT).returncode != 0
|
||||
|
||||
|
||||
def bisect(since, timeout, comment):
|
||||
failed = []
|
||||
if LAST_RUN.exists():
|
||||
failed = json.loads(LAST_RUN.read_text(encoding="utf-8")).get("failed") or []
|
||||
if not failed:
|
||||
print("No failed harness recorded: nothing to bisect.")
|
||||
return 0
|
||||
head = git("rev-parse", "HEAD").stdout.strip()
|
||||
commits = git("rev-list", "--first-parent", "--reverse", f"{since}..{head}").stdout.split()
|
||||
if not commits:
|
||||
print("No merge since the base.")
|
||||
return 0
|
||||
|
||||
def fails(commit):
|
||||
print(f"--- {commit[:9]} {git('log', '-1', '--format=%s', commit).stdout.strip()}", flush=True)
|
||||
git("checkout", "-q", "--detach", commit)
|
||||
return run_harnesses(failed, timeout)
|
||||
|
||||
try:
|
||||
culprit = first_bad(commits, fails) if len(commits) > 1 else commits[0]
|
||||
finally:
|
||||
git("checkout", "-q", "--detach", head)
|
||||
subject = git("log", "-1", "--format=%s", culprit).stdout.strip()
|
||||
number = re.search(r"Merge pull request #(\d+)", subject)
|
||||
line = (f"parity-main: {', '.join(failed)} first fail at {culprit[:9]} ({subject}), "
|
||||
f"of {len(commits)} merges since {since[:9]}.")
|
||||
print(line)
|
||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
|
||||
summary.write(f"### {line}\n")
|
||||
if comment and number:
|
||||
url = f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{REPO}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}"
|
||||
body = (f"🤖 Agent: parity-main found that this merge breaks {', '.join(f'`{name}`' for name in failed)} on main "
|
||||
f"(bisected over {len(commits)} merges: {url}). Releases wait until parity-main passes again.")
|
||||
subprocess.run(["gh", "pr", "comment", number.group(1), "--repo", REPO, "--body", body])
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("base")
|
||||
bisecting = commands.add_parser("bisect")
|
||||
bisecting.add_argument("since", help="the last commit known to pass (the run's base)")
|
||||
bisecting.add_argument("--timeout", type=int, default=2400, help="seconds per harness")
|
||||
bisecting.add_argument("--comment", action="store_true", help="comment on the breaking merge's pull request")
|
||||
args = parser.parse_args()
|
||||
if args.command == "base":
|
||||
print(base())
|
||||
return 0
|
||||
return bisect(args.since, args.timeout, args.comment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user