Compare commits

..
2305 changed files with 62173 additions and 256102 deletions

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
---
name: vanilla-parity
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
---
# Vanilla parity with the port ledger
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
Full reference: `docs/port-ledger.md`.
## 0. Have a fresh ledger
```sh
python3 tools/port_ledger.py build # ~20 s; again after pulling main
```
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
the lab first; don't guess vanilla from memory.
## 1. From the report to the code
```sh
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
```
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
already written there ("Type is not saved"). Then drill in:
```sh
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
```
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
line are in the output) and the Rust fn before changing anything.
## 2. Port it the vanilla way
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
## 3. Before the PR
```sh
python3 tools/port_ledger.py check --base origin/main
```
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
a small PR that says which vanilla method it now matches.
## Finding work
```sh
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
```
+1
View File
@@ -2,6 +2,7 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
-6
View File
@@ -1,6 +0,0 @@
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -10
View File
@@ -1,11 +1,2 @@
#!/bin/sh
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
exec python3 tools/check_public_tree.py
+1 -10
View File
@@ -1,11 +1,2 @@
#!/bin/sh
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py --pre-push
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
exec python3 tools/check_public_tree.py --history
+1
View File
@@ -6,6 +6,7 @@
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/tools/release_channels.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
-1
View File
@@ -1 +0,0 @@
0.2
-9
View File
@@ -1,9 +0,0 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+4 -6
View File
@@ -16,14 +16,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
# fork can have a branch of the same name).
for status in queued in_progress waiting pending requested; do
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
--json databaseId,event,headSha \
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
done | sort -u | while read -r id; do
gh run cancel -R "$REPO" "$id" || true
done
+99 -84
View File
@@ -3,8 +3,7 @@ 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. 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
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. 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:
@@ -19,10 +18,6 @@ concurrency:
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
group: checks-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
verify:
runs-on: ubuntu-latest
@@ -62,92 +57,18 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
if: github.event_name == 'pull_request'
id: launcher
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check the launcher's formatting, lint and tests
if: steps.launcher.outputs.required != 'false'
working-directory: launcher
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# 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
run: cargo +stable test --workspace
parity:
needs: parity-scope
if: needs.parity-scope.outputs.run == 'true'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
@@ -500,7 +421,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
jfr-analysis:
portable-mod:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -512,7 +433,101 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
-292
View File
@@ -1,292 +0,0 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
on:
workflow_call:
inputs:
ref:
description: The commit to build
type: string
required: true
version:
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
type: string
required: true
nightly:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
trusted:
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
# (tests/test_nightly.py pins all of it).
description: The source is main (a release); false for anything else (a nightly)
type: boolean
default: false
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
default: true
permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: exporters
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: packaging/macos/keychain.sh lock
+4 -7
View File
@@ -49,10 +49,6 @@ concurrency:
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
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:
decide:
runs-on: ubuntu-latest
@@ -192,11 +188,12 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
-34
View File
@@ -1,34 +0,0 @@
name: mod-registry
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
pull_request:
paths:
- 'crates/horizon-mods/**'
- 'crates/horizon-mod-registry/**'
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-mod-registry
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets --locked -- -D warnings
- run: cargo +stable test --locked
-278
View File
@@ -1,278 +0,0 @@
name: nightly build
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
on:
schedule:
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
workflow_dispatch:
inputs:
force:
description: Build and publish even if tonight's pick is the commit already published
type: boolean
default: false
dry_run:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
issue:
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
type: string
default: ''
request:
description: The command's id, in the run's name so it can link the run
type: string
default: ''
prs:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
base:
description: The commit of main to build on (default main's newest with green checks and parity-main)
type: string
default: ''
urgent:
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
type: boolean
default: false
permissions:
contents: read
concurrency:
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
# is lost.
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
actions: read # main's newest green checks and parity-main runs (the base)
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
short: ${{ steps.pick.outputs.short }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: pick
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
NIGHTLY_BASE: ${{ inputs.base }}
NIGHTLY_URGENT: ${{ inputs.urgent }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
path: nightly-notes.md
retention-days: 3
build:
needs: pick
if: needs.pick.outputs.build == 'true'
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.pick.outputs.sha }}
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
sign:
needs: [pick, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [pick, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
answer:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ inputs.issue }}
BUILD: ${{ needs.pick.outputs.build }}
PUBLISHED: ${{ needs.publish.result }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [[ "$BUILD" != true ]]; then
text="nothing new: the published Nightly is already this build."
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
else
text="the build failed, nothing was published: $RUN"
fi
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
-46
View File
@@ -1,46 +0,0 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
# is never read.
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
# a shell line.
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # the nightly-state branch (main is protected)
actions: write # starts `nightly build`
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+8 -19
View File
@@ -8,7 +8,7 @@ name: nightly audit
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
@@ -23,27 +23,17 @@ permissions:
concurrency:
group: nightly-audit
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:
audit:
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
runs-on: [self-hosted, horizon-game, macOS]
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
runs-on: [self-hosted, horizon-game]
timeout-minutes: 150
steps:
- name: 03:07 in Paris
- name: 03:00 in Paris
id: when
env:
SCHEDULE: ${{ github.event.schedule }}
run: |
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
offset=$(TZ=Europe/Paris date +%z)
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
@@ -71,8 +61,7 @@ jobs:
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
-77
View File
@@ -1,77 +0,0 @@
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:
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
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
id: harnesses
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
continue-on-error: true
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: A flake, or the merge that broke them
if: steps.harnesses.outcome == '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: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: ignore
retention-days: 7
+6 -53
View File
@@ -1,6 +1,6 @@
name: parity
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# 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). Called by ci.yml for pull requests from this repository
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
on:
workflow_call:
@@ -13,79 +13,32 @@ on:
description: The commit to check out and test
type: string
required: true
full:
description: >-
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
busy the job waits for it.
type: boolean
default: false
permissions:
contents: read
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
runs-on: [self-hosted, horizon-game]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
# The commit the event names: a fork's later pushes need another run-parity label.
ref: ${{ inputs.head }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- name: The head merged into main (what merging it would land)
id: merge
env:
BASE_SHA: ${{ inputs.base }}
HEAD_SHA: ${{ inputs.head }}
run: |
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
# request's own changes. A head that conflicts with main is tested alone.
set -euo pipefail
git merge --abort 2>/dev/null || true
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
main=$(git rev-parse origin/main)
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
echo "base=$main" >> "$GITHUB_OUTPUT"
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
else
git merge --abort
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- 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: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ steps.merge.outputs.base }}
run: |
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
fi
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: ${{ steps.merge.outputs.base }}
BASE_SHA: ${{ inputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
+52
View File
@@ -0,0 +1,52 @@
name: promote
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
# after the next promotion.
on:
schedule:
- cron: '13,43 * * * *'
workflow_dispatch:
inputs:
tag:
description: The beta to promote now (empty = the newest beta that is due)
type: string
default: ''
force:
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
type: boolean
default: false
dry_run:
description: Say what would be promoted, change nothing
type: boolean
default: false
permissions:
contents: write
issues: write
concurrency:
group: promote
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# The notes of a promoted release cover every pull request since the last stable one.
fetch-depth: 0
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
persist-credentials: false
- name: Promote
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force && 'yes' || '' }}
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
+316 -40
View File
@@ -1,19 +1,21 @@
name: release
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
# 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:
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
workflows: [checks, parity-main]
workflows: [checks]
types: [completed]
branches: [main]
schedule:
@@ -21,24 +23,17 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Decide only; start no Nightly build
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
description: Release even without player-facing changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
# The checks, parity-main and nightly build runs (decide).
actions: read
concurrency:
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
group: game-release
cancel-in-progress: false
jobs:
decide:
@@ -49,8 +44,9 @@ jobs:
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -63,28 +59,308 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# 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.
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
# main's newest commit with green checks.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
nightly:
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
build:
needs: decide
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write # starts `nightly build`
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
defaults:
run:
shell: bash
steps:
- env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ needs.decide.outputs.head }}
URGENT: ${{ needs.decide.outputs.urgent }}
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: packaging/macos/keychain.sh lock
sign:
needs: [decide, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
+2 -11
View File
@@ -5,20 +5,12 @@ name: run-parity
# skipped `verify`, and GitHub counts a skipped required check as passed.
on:
pull_request:
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
types: [labeled, synchronize]
types: [labeled]
permissions:
contents: read
concurrency:
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
group: run-parity-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
if: github.event.label.name == 'run-parity'
@@ -26,4 +18,3 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
-48
View File
@@ -1,48 +0,0 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
-3
View File
@@ -31,6 +31,3 @@
/.horizon-cache/
/crash-reports/
/debug/
# Release-only compiled original exporter bundle.
/exporters/
-190
View File
@@ -1,190 +0,0 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~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 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):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
## Vanilla reference
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
([docs/decompilation-status.md](docs/decompilation-status.md)).
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
## Docs worth knowing
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+3 -22
View File
@@ -4,13 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
## Build it
@@ -44,17 +39,6 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
## Before asking for review
@@ -86,9 +70,6 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
@@ -101,5 +82,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+679 -607
View File
File diff suppressed because it is too large. Load diff
+4 -49
View File
@@ -1,49 +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-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-command",
"crates/horizon-items",
"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",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "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/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
@@ -62,10 +19,8 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Copyright (c) 2026 Ludovic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+14 -24
View File
@@ -1,9 +1,9 @@
<p align="center">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
</p>
<p align="center">
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
@@ -15,23 +15,22 @@
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
# Horizon
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -46,9 +45,9 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -57,8 +56,8 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
@@ -93,13 +92,13 @@ Commands block until the action is done and reply with what happened; errors are
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/horizon-rust/horizon.git
git clone https://github.com/ludovic111/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
@@ -110,10 +109,6 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
@@ -139,11 +134,6 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
@@ -163,7 +153,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
-1061
View File
File diff suppressed because it is too large. Load diff
+30 -13
View File
@@ -1,21 +1,38 @@
# Third-party notices
## CullLeaves
The optional leaf and mangrove-root face-culling behavior is adapted from
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
Its MIT license is reproduced below.
```text
MIT License
Copyright (c) 2020 MidnightDust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1
(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`).
## Pixelarticons
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
@@ -119,7 +119,7 @@ machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+2 -2
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
serde_json = "1.0.151"
[lints]
-341
View File
@@ -1,341 +0,0 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
use horizon_mods::{
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
use serde_json::{Value, json};
use std::path::Path;
/// How long the trial run plays: two seconds of game.
const TICKS: u64 = 40;
const FRAMES_PER_TICK: u64 = 3;
pub struct Report {
pub kind: Kind,
pub facts: Facts,
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
pub commands: Vec<String>,
/// What the mod showed during the trial run.
pub output: Vec<String>,
}
impl Report {
pub fn json(&self) -> Value {
let requires: Vec<Value> = self
.facts
.requires
.iter()
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
.collect();
json!({
"kind": self.kind.name(),
"id": self.facts.id,
"version": self.facts.version,
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"api_level": self.facts.api_level,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
})
}
pub fn text(&self) -> String {
let f = &self.facts;
let mut out = format!(
"{} {} ({}",
f.id.as_deref().unwrap_or("resource pack"),
f.version.as_deref().unwrap_or(""),
self.kind.name()
);
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if self.kind == Kind::Shader {
out += &format!(", shader API {}", f.api);
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
if let Some(game) = &f.game {
out += &format!(", for {game}");
}
let signed = if self.signed {
"signature verified"
} else {
"unsigned"
};
out += &format!(
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
if let Some(level) = f.api_level {
out += &format!(" needs mod API level {level} or newer\n");
}
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
format!(", /{}", self.commands.join(", /"))
};
out += &format!(
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
TICKS * FRAMES_PER_TICK
);
}
out
}
}
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
_ => None,
}
}
/// Checks the file at `path` as `kind`; `key` and `signature` (the
/// `.minisig` text) check its signature.
pub fn check(
path: &Path,
kind: Kind,
key: Option<&str>,
signature: Option<&str>,
) -> Result<Report, String> {
let size = std::fs::metadata(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.len();
if size > index::MAX_FILE {
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
}
index::open(&kind)?;
let facts = index::inspect(&kind, path)?;
let signed = match (key, signature) {
(Some(key), Some(sig)) => {
index::verify_signature(key, sig, path)?;
true
}
(None, None) => false,
_ => return Err("--key and --signature go together".into()),
};
let mut report = Report {
kind,
facts,
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
};
if report.kind == Kind::Mod {
trial_run(path, &mut report)?;
}
Ok(report)
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
.check_imports(report.imports.iter().map(String::as_str))?;
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let copy = dir.join("mod.hmod");
let result = std::fs::copy(path, &copy)
.map_err(|e| e.to_string())
.and_then(|_| play(&copy, report));
let _ = std::fs::remove_dir_all(&dir);
result
}
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
let failed = |mods: &Mods| match &mods.entries()[0].status {
Status::Failed(reason) => Some(reason.clone()),
_ => None,
};
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
});
}
let shapes = mods.hud_shapes().count();
if shapes > 0 {
report.output.push(format!("drew {shapes} HUD shape(s)"));
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-mods/tests/index")
.join(name)
}
#[test]
fn checks_each_kind_its_own_way() {
let pack = fixture("cozy-1.0.0.hpack");
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
assert_eq!(report.facts.requires[0].id, "coords_hud");
assert!(
report
.text()
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
);
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
let hmod = fixture("coords_hud-0.1.0.hmod");
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
let key = key.lines().last().unwrap();
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
.err()
.unwrap();
assert!(e.contains("didn't start"), "{e}");
let e = check(
&hmod,
Kind::Mod,
Some(key),
Some("untrusted comment: x\nRUQ=\n"),
)
.err()
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("horizon-shaders.toml"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
+22 -254
View File
@@ -1,31 +1,20 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
//! `tests/*.toml` scripts in the sandbox, with no game.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Capability, Content, Manifest, Package,
Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
mod check;
mod registry;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -39,10 +28,7 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -96,72 +82,19 @@ fn new(args: &[String]) -> Result<(), String> {
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let code = [
let files = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
(
"tests/hello.toml",
include_str!("../template/tests/hello.toml"),
),
];
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
),
(
"data/{id}/horizon/block/glowing_amethyst.json",
include_str!("../template/content/block.json"),
),
(
"assets/{id}/blockstates/glowing_amethyst.json",
include_str!("../template/content/blockstate.json"),
),
(
"assets/{id}/models/block/glowing_amethyst.json",
include_str!("../template/content/model.json"),
),
(
"assets/{id}/items/glowing_amethyst.json",
include_str!("../template/content/item.json"),
),
(
"assets/{id}/lang/en_us.json",
include_str!("../template/content/lang.json"),
),
(
"data/{id}/loot_table/blocks/glowing_amethyst.json",
include_str!("../template/content/loot.json"),
),
(
"data/{id}/recipe/glowing_amethyst.json",
include_str!("../template/content/recipe.json"),
),
(
"data/minecraft/tags/block/mineable/pickaxe.json",
include_str!("../template/content/pickaxe.json"),
),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
let file = file.as_str();
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
let manifest =
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
Content::read(dir, &manifest)?;
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
@@ -169,53 +102,14 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
fn build(release: bool) -> Result<PathBuf, String> {
build_in(Path::new("."), release)
}
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
let manifest_text = std::fs::read_to_string(MANIFEST)
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
return Err(format!(
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
));
};
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
let mut files = Vec::with_capacity(content.files.len());
for name in &content.files {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.current_dir(root).args([
cmd.args([
"build",
"--lib",
"--target",
@@ -243,96 +137,17 @@ fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
/// when one does.
fn check(args: &[String]) -> Result<(), String> {
let file = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod check <file>")?;
let path = Path::new(file);
let kind = match flag(args, "--kind") {
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
None => check::kind_of(path)
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
};
let signature = flag(args, "--signature")
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()?;
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
if args.iter().any(|a| a == "--json") {
let json = match &report {
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
Err(e) => serde_json::json!({"ok": false, "error": e}),
};
println!("{json}");
}
let report = report?;
if !args.iter().any(|a| a == "--json") {
print!("{}", report.text());
}
Ok(())
}
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
None => build(!args.iter().any(|a| a == "--debug"))?,
};
let filter = args
.iter()
.enumerate()
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
.map(|(_, a)| a.as_str());
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
.map_err(|e| {
format!(
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
)
})?
.filter_map(|e| Some(e.ok()?.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
.collect();
scripts.sort();
if scripts.is_empty() {
return Err("no tests/*.toml to run".into());
}
println!(
"running {} scripts against {}",
scripts.len(),
hmod.display()
);
let mut failed = 0;
for path in &scripts {
let result = std::fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| Script::parse(&text))
.and_then(|script| {
let mut game = TestGame::load(&hmod)?;
script.run(&mut game)?;
Ok(script.steps.len())
});
match result {
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
Err(e) => {
failed += 1;
println!("test {} ... FAILED\n {e}", path.display());
}
}
}
let passed = scripts.len() - failed;
if failed > 0 {
return Err(format!("{failed} failed, {passed} passed"));
}
println!("test result: ok. {passed} passed");
Ok(())
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
fn run(args: &[String]) -> Result<(), String> {
@@ -377,57 +192,10 @@ mod tests {
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
let path = dir.join("gems");
let new_args = [
path.display().to_string(),
"--template".into(),
"content".into(),
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path, true).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
let content = Content::read(&hmod, &package.manifest).unwrap();
assert!(
content
.files
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
assert!(
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
])
.is_err()
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
-159
View File
@@ -1,159 +0,0 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
registry::{self, Sources},
};
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
}
fn positional(args: &[String]) -> Vec<&str> {
let mut out = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
} else if a == "--json" {
} else if a.starts_with("--") {
skip = true;
} else {
out.push(a.as_str());
}
}
out
}
fn lines(errors: Vec<String>) -> String {
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
}
pub fn main(args: &[String]) -> Result<(), String> {
let rest = args.get(1..).unwrap_or_default();
let pos = positional(rest);
match args.first().map(String::as_str) {
Some("build") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
let (Some(files), Some(out)) = (files, out) else {
return Err(USAGE.into());
};
let generated = flag(rest, "--generated")
.map(str::to_owned)
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
.unwrap_or_default();
let index = registry::build(&Sources {
registry: Path::new(checkout),
files: Path::new(files),
release: flag(rest, "--release").unwrap_or(FILE_HOST),
site: flag(rest, "--site").unwrap_or(SITE),
generated,
})
.map_err(lines)?;
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
println!(
"Wrote {out}: {} project(s), {} taken down",
index.projects.len(),
index.revoked.len()
);
Ok(())
}
Some("review") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
} else {
for n in &review.notes {
println!("note: {n}");
}
for e in &review.errors {
println!("error: {e}");
}
}
if review.errors.is_empty() {
Ok(())
} else {
Err(format!(
"{} change(s) the registry refuses",
review.errors.len()
))
}
}
// `check` (and its trial run) on each file `head` lists and `base`
// doesn't: a pull request's new files.
Some("check") => {
let [base, head] = pos[..] else {
return Err(USAGE.into());
};
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
let old: Vec<String> = match registry::load(Path::new(base)) {
Ok((entries, _)) => entries
.iter()
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
.collect(),
// The first pull request has no entries to compare with.
Err(_) => Vec::new(),
};
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
let mut failed = 0;
for e in &entries {
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
println!("{} {} ({}):", e.id, v.version, v.file);
let report = crate::check::check(
&files.join(&v.sha256),
e.kind.clone(),
e.key.as_deref(),
v.signature.as_deref(),
);
match report {
Ok(r) => print!("{}", r.text()),
Err(err) => {
failed += 1;
println!(" error: {err}");
}
}
}
}
if failed == 0 {
Ok(())
} else {
Err(format!("{failed} file(s) failed their check"))
}
}
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
Some("list") => {
let [checkout] = pos[..] else {
return Err(USAGE.into());
};
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
for e in &entries {
for v in &e.versions {
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
}
}
Ok(())
}
_ => Err(USAGE.into()),
}
}
@@ -1,4 +0,0 @@
{
"template": "minecraft:amethyst_block",
"properties": { "hardness": 2.0, "light": 7 }
}
@@ -1,5 +0,0 @@
{
"variants": {
"": { "model": "{id}:block/glowing_amethyst" }
}
}
@@ -1,10 +0,0 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
capabilities = ["registries"]
@@ -1,3 +0,0 @@
{
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
}
@@ -1,3 +0,0 @@
{
"block.{id}.glowing_amethyst": "Glowing Amethyst"
}
@@ -1,11 +0,0 @@
{
"type": "minecraft:block",
"pools": [
{
"condition": { "type": "minecraft:survives_explosion" },
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
"rolls": 1
}
],
"random_sequence": "{id}:blocks/glowing_amethyst"
}
@@ -1,4 +0,0 @@
{
"parent": "minecraft:block/cube_all",
"textures": { "all": "minecraft:block/amethyst_block" }
}
@@ -1,3 +0,0 @@
{
"values": ["{id}:glowing_amethyst"]
}
@@ -1,6 +0,0 @@
{
"type": "minecraft:crafting_shapeless",
"category": "building",
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
"result": { "id": "{id}:glowing_amethyst" }
}
@@ -1,14 +0,0 @@
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
# steps happen in order, and `expect` checks what the mod showed during the step.
# Every key: docs/mods.md, "Testing a mod".
[[step]]
expect.commands = ["hello"]
[[step]]
command = "hello world"
expect.chat = ["Hello world! (1 so far)"]
[[step]]
command = "hello again"
expect.chat = ["Hello again! (2 so far)"]
+15 -50
View File
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
use horizon_core::random::RandomSource;
#[derive(Clone, Debug)]
enum Choice<T> {
Single(T),
enum Choice {
Single(Variant),
Weighted {
variants: Vec<T>,
variants: Vec<Variant>,
ends: Vec<i32>,
total: i32,
},
}
impl Choice<Variant> {
impl Choice {
fn new(value: &VariantSet) -> Result<Self> {
match value {
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
@@ -41,23 +41,7 @@ impl Choice<Variant> {
}
}
}
}
impl<T> Choice<T> {
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
match self {
Self::Single(value) => Choice::Single(f(value)),
Self::Weighted {
variants,
ends,
total,
} => Choice::Weighted {
variants: variants.iter().map(&mut *f).collect(),
ends: ends.clone(),
total: *total,
},
}
}
fn select(&self, random: &mut impl RandomSource) -> &T {
fn select(&self, random: &mut impl RandomSource) -> &Variant {
match self {
Self::Single(value) => value,
Self::Weighted {
@@ -76,11 +60,10 @@ impl<T> Choice<T> {
}
/// Compile once per definition and share across its bound block states. Prefix
/// sums avoid rebuilding weight totals or scanning all entries during selection.
/// `map` swaps each variant for what it stands for (its baked model).
#[derive(Clone, Debug)]
pub struct CompiledChoices<T = Variant> {
variants: Vec<Choice<T>>,
multipart: Vec<Choice<T>>,
pub struct CompiledChoices {
variants: Vec<Choice>,
multipart: Vec<Choice>,
}
impl CompiledChoices {
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
@@ -99,19 +82,10 @@ impl CompiledChoices {
.collect::<Result<_>>()?,
})
}
}
impl<T> CompiledChoices<T> {
/// The same choices, with `f` of each variant in its place.
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
CompiledChoices {
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
}
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
let choice = match selected {
SelectedModels::Variant(index) => self.variants.get(*index)?,
SelectedModels::Multipart(_) => self.multipart.first()?,
@@ -121,23 +95,14 @@ impl<T> CompiledChoices<T> {
Choice::Weighted { variants, .. } => variants.first()?,
})
}
/// Append selected variants without allocating intermediate part lists.
/// Append selected variants without allocating intermediate part lists. A
/// multipart root consumes nextLong even for an empty selected subset, then
/// resets the same random source to that seed before each selected part.
pub fn collect<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
output: &mut Vec<&'a T>,
) -> Result<()> {
self.for_each(selected, random, |v| output.push(v))
}
/// Each selected variant, in order. A multipart root consumes nextLong
/// even for an empty selected subset, then resets the same random source
/// to that seed before each selected part.
pub fn for_each<'a>(
&'a self,
selected: &SelectedModels,
random: &mut impl RandomSource,
mut f: impl FnMut(&'a T),
output: &mut Vec<&'a Variant>,
) -> Result<()> {
match selected {
SelectedModels::Variant(index) => {
@@ -145,7 +110,7 @@ impl<T> CompiledChoices<T> {
.variants
.get(*index)
.ok_or_else(|| error("invalid variant selection"))?;
f(choice.select(random));
output.push(choice.select(random));
}
SelectedModels::Multipart(indices) => {
if indices.iter().any(|index| *index >= self.multipart.len()) {
@@ -154,7 +119,7 @@ impl<T> CompiledChoices<T> {
let seed = random.next_long();
for index in indices {
random.set_seed(seed);
f(self.multipart[*index].select(random));
output.push(self.multipart[*index].select(random));
}
}
}
+7 -10
View File
@@ -1031,22 +1031,19 @@ pub struct ItemModels {
}
impl ItemModels {
/// The stack's components: its item's defaults with the stack's patch on top. A stack
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
static NONE: Components = BTreeMap::new();
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
pub fn components(&self, stack: &StackView) -> Components {
let mut out = self
.prototypes
.get(&stack.item)
.cloned()
.unwrap_or_default();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
std::borrow::Cow::Owned(out)
out
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
-2
View File
@@ -8,5 +8,3 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
+27 -52
View File
@@ -1,7 +1,8 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use horizon_telemetry::system::command_output as output;
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -15,22 +16,8 @@ pub struct Hardware {
pub cpu: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_threads: Option<usize>,
/// Physical cores.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_cores: Option<u16>,
/// Base and top boost clock, where the OS gives them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_base_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_boost_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_mb: Option<u64>,
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
/// `laptop` or `desktop`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<Gpu>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -46,9 +33,6 @@ pub struct Gpu {
pub backend: String,
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
pub kind: String,
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vram_mb: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -63,50 +47,17 @@ pub struct Display {
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
display: None,
}
}
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
struct Probes {
cores: Option<u16>,
base: Option<u32>,
boost: Option<u32>,
form: Option<String>,
}
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
@@ -131,7 +82,25 @@ pub fn load_average() -> Option<f64> {
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
if cfg!(target_os = "macos") {
let batt = output("pmset", &["-g", "batt"])?;
return Some(if batt.contains("'Battery Power'") {
"battery"
} else {
"ac"
});
}
let mut on_battery = None;
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
let read =
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
match read("type").trim() {
"Mains" if read("online").trim() == "1" => return Some("ac"),
"Battery" => on_battery = Some("battery"),
_ => {}
}
}
on_battery
}
/// Resident memory of a process, in bytes (macOS, Linux).
@@ -172,6 +141,12 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
Some(ticks / 100.0)
}
fn output(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
+1 -7
View File
@@ -245,19 +245,13 @@ pub struct Report {
impl Report {
/// An empty report for this machine, created now.
pub fn new(kind: Kind, app: App) -> Report {
Self::with_hardware(kind, app, Hardware::detect())
}
/// Starts a report using supplied hardware, without running system probes.
/// Interactive apps can collect the remaining machine details after their first frame.
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
Report {
format: FORMAT.to_owned(),
version: VERSION,
kind,
created: utc_now(),
app,
hardware,
hardware: Hardware::detect(),
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
+25 -25
View File
@@ -173,11 +173,6 @@ impl Random {
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
@@ -334,7 +329,7 @@ fn read(
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| lock(&writer).send(&body);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
let mut seen = HashSet::new();
let mut batch = BatchSize::new();
let mut teleports = 0u64;
@@ -343,7 +338,7 @@ fn read(
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*lock(&bot.error) = Some(e.to_string());
*bot.error.lock().unwrap() = Some(e.to_string());
}
return;
}
@@ -361,7 +356,7 @@ fn read(
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !lock(&bot.held).insert((x, z)) {
if !bot.held.lock().unwrap().insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
}
if seen.insert((x, z)) {
@@ -373,7 +368,7 @@ fn read(
cb::FORGET_LEVEL_CHUNK => {
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
play::Clientbound::decode(id, &body)
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
{
bot.forgotten.fetch_add(1, Ordering::Relaxed);
}
@@ -394,7 +389,7 @@ fn read(
}
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = lock(&bot.teleport);
let mut at = bot.teleport.lock().unwrap();
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
@@ -425,7 +420,7 @@ fn read(
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
@@ -460,7 +455,7 @@ fn run_bot(
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*lock(&bot.error) = Some(e);
*bot.error.lock().unwrap() = Some(e);
return;
}
};
@@ -474,12 +469,12 @@ fn run_bot(
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *lock(&bot.teleport) {
if let Some(at) = *bot.teleport.lock().unwrap() {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if lock(&bot.error).is_none() {
*lock(&bot.error) = Some("no spawn position within 60 s".into());
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
}
return;
}
@@ -487,7 +482,7 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
@@ -496,15 +491,17 @@ fn run_bot(
let mut target = pos;
let mut next = Instant::now();
loop {
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = lock(&bot.teleport).take() {
if let Some(at) = bot.teleport.lock().unwrap().take() {
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !lock(&bot.held).contains(&here) {
if !bot.held.lock().unwrap().contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -561,7 +558,7 @@ fn run_bot(
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
lock(&writer).shutdown();
writer.lock().unwrap().shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
@@ -615,10 +612,13 @@ fn main() {
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
};
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
@@ -636,10 +636,10 @@ fn main() {
})
);
previous_chunks = chunks;
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*lock(&until) = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now());
break;
}
}
@@ -669,7 +669,7 @@ fn main() {
"forgotten": b.forgotten.load(Ordering::Relaxed),
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *lock(&b.error),
"error": *b.error.lock().unwrap(),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
horizon-world = { path = "../horizon-world" }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[lints]
workspace = true
@@ -1,35 +0,0 @@
//! Resource identifiers (`Identifier`).
/// Identifier.parse: `ns:path` or `path`, validated.
pub fn parse_identifier(raw: &str) -> Option<String> {
let (ns, path) = match raw.split_once(':') {
Some(("", path)) => ("minecraft", path),
Some((ns, path)) => (ns, path),
None => ("minecraft", raw),
};
let ns_ok = ns != ".."
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
@@ -1,38 +0,0 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
-28
View File
@@ -1,28 +0,0 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
pub mod component;
pub mod fastutil_order;
pub mod gamerules;
pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -1,32 +0,0 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
/// Any numeric tag as f64, keeping a float's or double's fraction.
pub fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
@@ -1,209 +0,0 @@
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
//! implementation, by registry name, and their per-type flags.
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
/// Vanilla particle types with a client implementation here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ParticleType { $($variant),* }
impl ParticleType {
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
pub fn name(self) -> &'static str {
match self { $(ParticleType::$variant => $name),* }
}
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
match name { $($name => Some(ParticleType::$variant),)* _ => None }
}
}
};
}
particle_types! {
Block = "block",
BlockCrumble = "block_crumble",
Trail = "trail",
DustPillar = "dust_pillar",
FallingDust = "falling_dust",
Smoke = "smoke",
LargeSmoke = "large_smoke",
WhiteSmoke = "white_smoke",
Flame = "flame",
SoulFireFlame = "soul_fire_flame",
CopperFireFlame = "copper_fire_flame",
SmallFlame = "small_flame",
Lava = "lava",
DrippingWater = "dripping_water",
FallingWater = "falling_water",
DrippingLava = "dripping_lava",
FallingLava = "falling_lava",
LandingLava = "landing_lava",
DrippingHoney = "dripping_honey",
FallingHoney = "falling_honey",
LandingHoney = "landing_honey",
FallingNectar = "falling_nectar",
FallingSporeBlossom = "falling_spore_blossom",
SporeBlossomAir = "spore_blossom_air",
DrippingObsidianTear = "dripping_obsidian_tear",
FallingObsidianTear = "falling_obsidian_tear",
LandingObsidianTear = "landing_obsidian_tear",
DrippingDripstoneWater = "dripping_dripstone_water",
FallingDripstoneWater = "falling_dripstone_water",
DrippingDripstoneLava = "dripping_dripstone_lava",
FallingDripstoneLava = "falling_dripstone_lava",
Splash = "splash",
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
Underwater = "underwater",
Portal = "portal",
ReversePortal = "reverse_portal",
Enchant = "enchant",
Nautilus = "nautilus",
VaultConnection = "vault_connection",
Crit = "crit",
EnchantedHit = "enchanted_hit",
DamageIndicator = "damage_indicator",
Heart = "heart",
AngryVillager = "angry_villager",
HappyVillager = "happy_villager",
Composter = "composter",
Mycelium = "mycelium",
Dolphin = "dolphin",
EggCrack = "egg_crack",
Poof = "poof",
Explosion = "explosion",
ExplosionEmitter = "explosion_emitter",
CampfireCosySmoke = "campfire_cosy_smoke",
CampfireSignalSmoke = "campfire_signal_smoke",
Note = "note",
Dust = "dust",
TotemOfUndying = "totem_of_undying",
Firework = "firework",
EndRod = "end_rod",
Ash = "ash",
WhiteAsh = "white_ash",
CrimsonSpore = "crimson_spore",
WarpedSpore = "warped_spore",
CherryLeaves = "cherry_leaves",
PaleOakLeaves = "pale_oak_leaves",
TintedLeaves = "tinted_leaves",
Effect = "effect",
InstantEffect = "instant_effect",
EntityEffect = "entity_effect",
Witch = "witch",
Infested = "infested",
Cloud = "cloud",
Sneeze = "sneeze",
Snowflake = "snowflake",
Soul = "soul",
SculkSoul = "sculk_soul",
DragonBreath = "dragon_breath",
Gust = "gust",
SmallGust = "small_gust",
GustEmitterLarge = "gust_emitter_large",
GustEmitterSmall = "gust_emitter_small",
DustColorTransition = "dust_color_transition",
SculkCharge = "sculk_charge",
SculkChargePop = "sculk_charge_pop",
Shriek = "shriek",
SonicBoom = "sonic_boom",
Vibration = "vibration",
SweepAttack = "sweep_attack",
Glow = "glow",
WaxOn = "wax_on",
WaxOff = "wax_off",
ElectricSpark = "electric_spark",
Scrape = "scrape",
SquidInk = "squid_ink",
GlowSquidInk = "glow_squid_ink",
Item = "item",
ItemSlime = "item_slime",
ItemSnowball = "item_snowball",
ItemCobweb = "item_cobweb",
SulfurCubeGoo = "sulfur_cube_goo",
Flash = "flash",
Firefly = "firefly",
TrialSpawnerDetection = "trial_spawner_detection",
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
TrialOmen = "trial_omen",
RaidOmen = "raid_omen",
OminousSpawning = "ominous_spawning",
Spit = "spit",
Fishing = "fishing",
DustPlume = "dust_plume",
RedPoplarLeaves = "red_poplar_leaves",
OrangePoplarLeaves = "orange_poplar_leaves",
YellowPoplarLeaves = "yellow_poplar_leaves",
PauseMobGrowth = "pause_mob_growth",
ResetMobGrowth = "reset_mob_growth",
BlockMarker = "block_marker",
}
impl ParticleType {
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
/// spawned regardless of distance.
pub fn override_limiter(self) -> bool {
matches!(
self,
Self::BlockMarker
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
| Self::Gust
| Self::GustEmitterLarge
| Self::GustEmitterSmall
| Self::SonicBoom
| Self::SculkCharge
| Self::SculkChargePop
| Self::Vibration
| Self::Poof
| Self::Spit
| Self::SquidInk
| Self::SweepAttack
| Self::CampfireCosySmoke
| Self::CampfireSignalSmoke
| Self::GlowSquidInk
| Self::Glow
| Self::WaxOn
| Self::WaxOff
| Self::ElectricSpark
| Self::Scrape
| Self::TrialSpawnerDetection
| Self::TrialSpawnerDetectionOminous
| Self::VaultConnection
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
-249
View File
@@ -1,249 +0,0 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
@@ -1,188 +0,0 @@
//! Registry contents and source state that commands validate and complete
//! arguments against (what the server's registry access holds).
use std::collections::BTreeMap;
/// Registry contents and source state used to validate and complete arguments.
#[derive(Clone, Debug, Default)]
pub struct Registries {
/// Permission level of the command source.
pub permission: u8,
pub items: Vec<String>,
pub item_tags: Vec<String>,
/// Block id -> (property, allowed values).
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
pub block_tags: Vec<String>,
/// Entity types that `summon` can create.
pub summonable: Vec<String>,
/// Every entity type (selector `type=`).
pub entity_types: Vec<String>,
pub effects: Vec<String>,
pub enchantments: Vec<String>,
pub structures: Vec<String>,
pub biomes: Vec<String>,
pub dimensions: Vec<String>,
pub clocks: Vec<String>,
pub timelines: Vec<String>,
pub time_markers: Vec<String>,
pub advancements: Vec<String>,
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
pub tracked: Vec<String>,
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
pub functions: Vec<String>,
/// `/data ... storage` ids.
pub storages: Vec<String>,
/// Boss bar ids and datapack predicate ids.
pub bossbars: Vec<String>,
pub predicates: Vec<String>,
pub item_modifiers: Vec<String>,
pub schedules: Vec<String>,
pub stopwatches: Vec<String>,
pub recipes: Vec<String>,
pub sounds: Vec<String>,
pub damage_types: Vec<String>,
pub attributes: Vec<String>,
pub loot_tables: Vec<String>,
pub templates: Vec<String>,
pub random_sequences: Vec<String>,
pub datapacks_enabled: Vec<String>,
pub datapacks_disabled: Vec<String>,
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
/// Blocks whose default state has a block entity.
pub block_entities: std::collections::BTreeSet<String>,
/// Block tag -> member blocks.
pub block_tag_members: BTreeMap<String, Vec<String>>,
/// Data component types with a codec (not transient).
pub persistent_components: Vec<String>,
/// Recipes `/recipe` can give (not special).
pub learnable_recipes: Vec<String>,
/// Advancement id -> criterion names.
pub advancement_criteria: BTreeMap<String, Vec<String>>,
/// World clock -> time markers shown in commands.
pub clock_time_markers: BTreeMap<String, Vec<String>>,
/// Trigger objectives the source may use (`/trigger` suggestions).
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
pub struct RegistryIds {
pub ids: Vec<String>,
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.ids;
}
match registry {
"minecraft:item" => &self.items,
"minecraft:entity_type" => &self.entity_types,
"minecraft:mob_effect" => &self.effects,
"minecraft:enchantment" => &self.enchantments,
"minecraft:worldgen/structure" => &self.structures,
"minecraft:worldgen/biome" => &self.biomes,
"minecraft:world_clock" => &self.clocks,
"minecraft:timeline" => &self.timelines,
"minecraft:attribute" => &self.attributes,
"minecraft:damage_type" => &self.damage_types,
"minecraft:sound_event" => &self.sounds,
"minecraft:predicate" => &self.predicates,
"minecraft:item_modifier" => &self.item_modifiers,
"minecraft:loot_table" => &self.loot_tables,
"minecraft:recipe" => &self.recipes,
"minecraft:advancement" => &self.advancements,
_ => EMPTY,
}
}
/// The tag ids of a registry (sorted).
pub fn tags(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.tags;
}
match registry {
"minecraft:item" => &self.item_tags,
"minecraft:block" => &self.block_tags,
_ => EMPTY,
}
}
}
@@ -1,7 +0,0 @@
//! Sound sources (`SoundSource`), by name.
/// SoundSource names in vanilla order.
pub const SOUND_SOURCES: [&str; 11] = [
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
"voice", "ui",
];
-24
View File
@@ -1,24 +0,0 @@
//! UUIDs as vanilla's four-int form, to and from their text form.
pub fn uuid_text(u: [i32; 4]) -> String {
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
-8
View File
@@ -1,8 +0,0 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
-14
View File
@@ -1,14 +0,0 @@
//! The client's input layer, below gameplay and screens: key bindings and their
//! names, toggle keys, mouse settings, controllers, the clipboard and window
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod input;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod mouse;
pub mod window;
-67
View File
@@ -1,67 +0,0 @@
[package]
name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output: horizon-client's `audio` feature.
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[lints]
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
-302
View File
@@ -1,302 +0,0 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -1,469 +0,0 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
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>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
impl Default for Spawner {
fn default() -> Self {
Self {
spawn_delay: 20,
potentials: Vec::new(),
next: None,
min_spawn_delay: 200,
max_spawn_delay: 800,
spawn_count: 4,
max_nearby_entities: 6,
required_player_range: 16,
spawn_range: 4,
client_delay: 20,
spin: 0.0,
o_spin: 0.0,
}
}
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
impl Default for TrialConfig {
/// `TrialSpawnerConfig.Builder`'s defaults.
fn default() -> Self {
Self {
spawn_range: 4,
total_mobs: 6.0,
simultaneous_mobs: 2.0,
total_mobs_added_per_player: 2.0,
simultaneous_mobs_added_per_player: 1.0,
ticks_between_spawn: 40,
spawn_potentials: Vec::new(),
loot_tables_to_eject: vec![
("minecraft:spawners/trial_chamber/consumables".into(), 1),
("minecraft:spawners/trial_chamber/key".into(), 1),
],
items_to_drop_when_ominous:
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
}
}
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
impl Default for TrialSpawner {
fn default() -> Self {
Self {
normal: ConfigSlot::default(),
ominous: ConfigSlot::default(),
target_cooldown_length: 36000,
required_player_range: 14,
detected_players: Vec::new(),
current_mobs: Vec::new(),
cooldown_ends_at: 0,
next_mob_spawns_at: 0,
total_mobs_spawned: 0,
next_spawn_data: None,
ejecting_loot_table: None,
is_ominous: false,
has_display: false,
spin: 0.0,
o_spin: 0.0,
dispensing: None,
}
}
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
@@ -1,543 +0,0 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
Self::Furnace => "minecraft:smelting",
Self::BlastFurnace => "minecraft:blasting",
Self::Smoker => "minecraft:smoking",
}
}
pub fn fast(self) -> bool {
self != Self::Furnace
}
pub fn from_block(name: &str) -> Option<Self> {
match name {
"minecraft:furnace" => Some(Self::Furnace),
"minecraft:blast_furnace" => Some(Self::BlastFurnace),
"minecraft:smoker" => Some(Self::Smoker),
_ => None,
}
}
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
/// ("Duplicate entry for key").
pub fn recipe_used_key(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
/// translation key of its menu's title when it has no custom name. Copper
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
/// `ShulkerBoxBlockEntity`.
pub fn default_name_key(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
n if n.ends_with("copper_chest") => "container.chest",
"minecraft:barrel" => "container.barrel",
n if n.ends_with("shulker_box") => "container.shulkerBox",
"minecraft:dispenser" => "container.dispenser",
"minecraft:dropper" => "container.dropper",
"minecraft:hopper" => "container.hopper",
"minecraft:furnace" => "container.furnace",
"minecraft:smoker" => "container.smoker",
"minecraft:blast_furnace" => "container.blast_furnace",
"minecraft:brewing_stand" => "container.brewing",
"minecraft:crafter" => "container.crafter",
_ => return None,
})
}
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
/// name (`CustomName`, a literal), else its default name.
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
match custom {
Some(name) => horizon_protocol::text::literal(name),
None => horizon_protocol::text::translatable(default_key, &[]),
}
}
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
/// `FIRST`), else the second's, else `container.chestDouble`.
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
display_name(first.or(second), "container.chestDouble")
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
note_block_sound: Option<String>,
custom_name: Option<horizon_world::nbt::Tag>,
) -> Self {
let skin = profile.as_ref().map(SkullSkin::of);
Self {
profile,
note_block_sound,
custom_name,
skin,
}
}
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self {
download,
dynamic,
fallback,
}
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
slot < 9 && self.items[slot].is_none()
}
/// `setSlotState`.
pub fn set_slot_state(&mut self, slot: usize, enabled: bool) {
if self.slot_can_be_disabled(slot) {
self.disabled[slot] = !enabled;
}
}
/// `getRedstoneSignal`: filled or disabled slots.
pub fn redstone_signal(&self) -> i32 {
(0..9)
.filter(|&i| self.items[i].is_some() || self.disabled[i])
.count() as i32
}
/// `canPlaceItem`: a hopper fills the smallest stacks first.
pub fn can_place(&self, slot: usize, _stack: &ItemStack, max_stack: u32) -> bool {
if slot >= 9 || self.disabled[slot] {
return false;
}
let Some(current) = &self.items[slot] else {
return true;
};
if current.count >= max_stack {
return false;
}
!(slot + 1..9).any(|i| {
!self.disabled[i]
&& match &self.items[i] {
None => true,
Some(other) => {
other.count < current.count && other.same_item_same_components(current)
}
}
})
}
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
return Some(Self::Sign(crate::signs::SignData {
hanging,
..Default::default()
}));
}
if let Some(kind) = FurnaceKind::from_block(name) {
return Some(Self::Furnace(Furnace {
kind,
items: [None, None, None],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
if name == "minecraft:spawner" {
return Some(Self::Spawner(Box::default()));
}
if name == "minecraft:trial_spawner" {
return Some(Self::TrialSpawner(Box::default()));
}
if name == "minecraft:vault" {
return Some(Self::Vault(Box::default()));
}
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
return Some(Self::Beehive(Box::default()));
}
if name == "minecraft:lectern" {
return Some(Self::Lectern(Lectern::default()));
}
if crate::command_block::Mode::of_block(name).is_some() {
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
return Some(Self::CommandBlock(Box::new(
crate::command_block::CommandBlock {
auto: crate::command_block::automatic_block(name),
..Default::default()
},
)));
}
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
return Some(Self::Sculk(Box::new(sculk)));
}
if name == "minecraft:crafter" {
// CrafterBlock.newBlockEntity copies the placed state's flag: the
// first tick sees it as a change from untriggered.
return Some(Self::Crafter(Crafter {
state_triggered: Some(false),
..Crafter::default()
}));
}
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
}
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
..Default::default()
})));
}
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
"minecraft:hopper" => HOPPER_SLOTS,
"minecraft:dispenser" | "minecraft:dropper" => 9,
n if n.ends_with("shulker_box") => 27,
_ => return None,
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &'static str {
match self {
Self::Chest(items) => match items.len() {
HOPPER_SLOTS => "minecraft:hopper",
9 => "minecraft:dispenser",
_ => "minecraft:chest",
},
Self::Furnace(f) => match f.kind {
FurnaceKind::Furnace => "minecraft:furnace",
FurnaceKind::BlastFurnace => "minecraft:blast_furnace",
FurnaceKind::Smoker => "minecraft:smoker",
},
Self::Brewing(_) => "minecraft:brewing_stand",
Self::Sign(s) => s.id(),
Self::Jukebox(_) => "minecraft:jukebox",
Self::Beacon(_) => "minecraft:beacon",
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
Self::TrialSpawner(_) => "minecraft:trial_spawner",
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
match self {
Self::Chest(items) => items.iter().flatten().cloned().collect(),
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
match self {
Self::Chest(items) => items,
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
match self {
Self::Chest(items) => items,
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
pub use crate::providers::Providers;
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
/// chests, trapped chests and copper chests in every weathering state, waxed
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
/// `EnderChestBlock`s and always single.
pub fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
/// A chest block's name with its `type` and `facing` properties.
#[derive(Clone, Copy, Debug)]
pub struct ChestHalf<'a> {
pub block: &'a str,
pub kind: &'a str,
pub facing: &'a str,
}
impl ChestHalf<'_> {
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
/// half: clockwise from the facing for a left half, counter-clockwise for
/// a right one. None for a single chest.
pub fn connected_step(&self) -> Option<(i32, i32)> {
let step = |facing: &str| match facing {
"north" => Some((0, -1)),
"south" => Some((0, 1)),
"west" => Some((-1, 0)),
"east" => Some((1, 0)),
_ => None,
};
let (x, z) = step(self.facing)?;
// Turning clockwise seen from above takes (x, z) to (-z, x).
match self.kind {
"left" => Some((-z, x)),
"right" => Some((z, -x)),
_ => None,
}
}
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
/// it: `other`, the block in this half's connected direction, is its other
/// half when it is the same block, a half of the other side and faces the
/// same way. Copper chests in different weathering states never join
/// (`CopperChestBlock` keeps both halves in one state instead).
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
is_chest_block(self.block)
&& self.kind != "single"
&& other.block == self.block
&& other.kind != "single"
&& other.kind != self.kind
&& other.facing == self.facing
}
}
/// `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};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// 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))));
}
}
@@ -1,449 +0,0 @@
// Entities, dropped items and particles: camera-relative vertices textured
// from the shared 2D atlas, lit through the same lightmap as terrain.
struct Globals {
view_proj: mat4x4<f32>,
fog_color: vec4<f32>,
fog: vec4<f32>,
sky_color: vec4<f32>,
misc: vec4<f32>,
inverse_view: mat4x4<f32>,
sunrise: vec4<f32>,
sun: vec4<f32>,
// x: sky fog end, y: cloud fog end
fog_ends: vec4<f32>,
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
// w: the glint scroll clock (`Glint::scroll_ticks`).
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
@group(0) @binding(3) var lightmap: texture_2d<f32>;
@group(0) @binding(4) var lightmap_sampler: sampler;
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
@group(0) @binding(6) var glint_sampler: sampler;
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
// the same repeating, linearly filtered sampler as the item glint.
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) spherical: f32,
@location(3) cylindrical: f32,
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
@location(5) overlay_color: vec4<f32>,
@location(4) @interpolate(flat) overlay: u32,
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
@location(6) @interpolate(flat) wrap: vec2<f32>,
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
@location(7) proj: vec4<f32>,
@location(8) back_color: vec4<f32>,
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
let l0 = normalize(vec3(0.2, 1.0, -0.7));
let l1 = normalize(vec3(-0.2, select(1.0, -1.0, nether), 0.7));
return min(1.0, (max(0.0, dot(l0, normal)) + max(0.0, dot(l1, normal))) * 0.6 + 0.4);
}
// The OverlayTexture texel entity.vsh fetches (constants from overlay.rs):
// the hurt red for overlays 1 and 8, the white row at column 15 for the
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
// swelling). Alpha 1 changes nothing.
fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
// level less per block. time.z of them are set: none when the extra is off, so no loop runs.
fn dynamic_block_light(pos: vec3<f32>) -> f32 {
let count = min(u32(globals.time.z), 16u);
var level = 0.0;
for (var i = 0u; i < count; i++) {
let l = globals.dynamic_lights[i];
level = max(level, l.w - distance(pos, l.xyz));
}
return level * 16.0;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
out.uv = uv;
let l = vec2(max(f32(light.y), dynamic_block_light(position)), f32(light.x));
let lm = textureSampleLevel(lightmap, lightmap_sampler,
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
// Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
out.color = c;
out.back_color = back;
out.overlay_color = overlay_texel(overlay);
// Sky geometry (clouds, celestial bodies) is exempt from render-distance fog.
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
out.glint_uv = glint_uv;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
colors: array<vec4<f32>, 4>,
}
@group(1) @binding(0) var<uniform> cloud: CloudInfo;
@vertex fn vs_cloud(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position + cloud.offset.xyz, uv, overlay, cloud.colors[light.x], light, normal);
}
// rendertype_end_portal.fsh: the layer colours.
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
vec3(0.011892, 0.095924, 0.089485),
vec3(0.027636, 0.101689, 0.100326),
vec3(0.046564, 0.109883, 0.114838),
vec3(0.064901, 0.117696, 0.097189),
vec3(0.063761, 0.086895, 0.123646),
vec3(0.084817, 0.111994, 0.166380),
vec3(0.097489, 0.154120, 0.091064),
vec3(0.106152, 0.131144, 0.195191),
vec3(0.097721, 0.110188, 0.187229),
vec3(0.133516, 0.138278, 0.148582),
vec3(0.070006, 0.243332, 0.235792),
vec3(0.196766, 0.142899, 0.214696),
vec3(0.047281, 0.315338, 0.321970),
vec3(0.204675, 0.390010, 0.302066),
vec3(0.080955, 0.314821, 0.661491),
);
// textureProj on a repeating texture that lives in the atlas at `rect`.
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
let st = fract(p.xy / p.w);
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
}
// texProj0 * end_portal_layer(layer): the row vector through mat4(scale * rotate),
// translate and SCALE_TRANSLATE in turn.
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
let c = cos(a);
let s = sin(a);
let k = (4.5 - layer / 4.0) * 2.0;
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (globals.time.x * 1.5) * v.w, v.z, v.w);
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d <= start { return 0.0; }
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
// common period of both offsets.
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
let ticks = globals.time.w;
let offset0 = (ticks % 110000.0) / 110000.0;
let offset1 = (ticks % 30000.0) / 30000.0;
let angle = 0.17453292;
let c = cos(angle);
let s = sin(angle);
let scaled = model * 0.16;
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
// different stages can fall on opposite sides of a nearest-sampling boundary.
let origin = round(packed_origin * size);
let extent = max(round(sprite_size * size), vec2(1.0));
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
return (origin + local + vec2(0.5)) / size;
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
let layers = select(15, 16, in.overlay == 10u);
var c = portal_sample(globals.end_portal_sprites[0], in.proj) * PORTAL_COLORS[0];
for (var i = 0; i < layers; i++) {
c += portal_sample(globals.end_portal_sprites[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
}
color = vec4(c, 1.0);
} else if in.overlay == 4u {
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
// 33: a translucent item with the glint.
// Enchantment glint: item.fsh's GLINT path, sampled from
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
} else if in.overlay == 13u {
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
return vec4(0.0);
}
// WEATHER_MATH_BEGIN
// Unsigned 64-bit helpers (low word first). Snow adds a random double to a
// float drift before casting to float. Retaining the 53-bit base avoids early
// rounding, including ties, without requiring optional GPU float64 support.
fn weather_shr_jam(v: vec2<u32>, n: u32) -> vec2<u32> {
if n == 0u { return v; }
if n < 32u {
return vec2((v.x >> n) | (v.y << (32u - n)) | select(0u, 1u, (v.x << (32u - n)) != 0u), v.y >> n);
}
if n == 32u { return vec2(v.y | select(0u, 1u, v.x != 0u), 0u); }
if n < 64u {
return vec2((v.y >> (n - 32u)) | select(0u, 1u, v.x != 0u || (v.y << (64u - n)) != 0u), 0u);
}
return vec2(select(0u, 1u, any(v != vec2(0u))), 0u);
}
fn weather_add64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
let lo = a.x + b.x;
return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x));
}
fn weather_sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
}
fn weather_double_plus_float(base: vec2<u32>, delta: f32) -> f32 {
// The random base is nonnegative and either zero or >= 2^-53.
if all(base == vec2(0u)) { return select(delta, 0.0, delta == 0.0); }
let db = bitcast<u32>(delta);
let mag = db & 0x7fffffffu;
let be = i32((base.y >> 20u) & 2047u) - 1023;
var de = i32(mag >> 23u) - 127;
var dm = (mag & 0x7fffffu) | 0x800000u;
if mag < 0x800000u {
dm = mag;
if dm == 0u { de = -200; }
else {
let shift = countLeadingZeros(dm) - 8u;
dm <<= shift;
de = -126 - i32(shift);
}
}
let e = max(be, de);
// Leading significand bit at 61, with room for addition and guard bits.
var a = vec2(base.x << 9u, ((base.y & 0xfffffu) | 0x100000u) << 9u | (base.x >> 23u));
var b = vec2(0u, dm << 6u);
a = weather_shr_jam(a, u32(e - be));
b = weather_shr_jam(b, u32(e - de));
var sign = 0u;
var value: vec2<u32>;
if (db >> 31u) == 0u { value = weather_add64(a, b); }
else if a.y > b.y || (a.y == b.y && a.x >= b.x) { value = weather_sub64(a, b); }
else { value = weather_sub64(b, a); sign = 0x80000000u; }
if all(value == vec2(0u)) { return 0.0; }
let leading = select(31u - countLeadingZeros(value.x), 63u - countLeadingZeros(value.y), value.y != 0u);
var exponent = e + i32(leading) - 61;
// Normalize the leading bit to 62. After jamming, bit 38 is the guard
// bit and bits 0..37 distinguish a tie from a value above the midpoint.
let shift = 62u - leading;
if shift >= 32u { value = vec2(0u, value.x << (shift - 32u)); }
else if shift != 0u { value = vec2(value.x << shift, (value.y << shift) | (value.x >> (32u - shift))); }
// Vanilla first rounds the addition to double, then casts to float.
// Round at bit 10 (53 retained bits) before the 24-bit rounding below;
// otherwise a double-rounding midpoint can select the wrong float.
let double_guard = (value.x & 512u) != 0u;
let double_sticky = (value.x & 511u) != 0u;
let double_odd = (value.x & 1024u) != 0u;
value.x &= 0xfffffc00u;
if double_guard && (double_sticky || double_odd) {
value = weather_add64(value, vec2(1024u, 0u));
}
if (value.y & 0x80000000u) != 0u {
value = weather_shr_jam(value, 1u);
exponent += 1;
}
var mantissa = value.y >> 7u;
let guard = (value.y & 64u) != 0u;
let sticky = (value.y & 63u) != 0u || value.x != 0u;
if guard && (sticky || (mantissa & 1u) != 0u) { mantissa += 1u; }
if mantissa >= 0x1000000u { mantissa >>= 1u; exponent += 1; }
return bitcast<f32>(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu));
}
fn weather_phase(animation: vec4<f32>, bases: vec4<u32>, snow: u32, time: vec4<f32>) -> vec2<f32> {
if snow == 0u {
let ticks = time.x + animation.y;
let t = ticks + time.z;
let step = -t / 32.0;
let offset = step * animation.x;
return vec2(0.0, offset % 32.0);
}
let t = time.x + time.z;
let u_time = t * 0.01;
let du = u_time * animation.z;
let v_time = t * animation.w;
let dv = v_time * 0.001;
let u = weather_double_plus_float(bases.xy, du);
let v = weather_double_plus_float(bases.zw, dv);
let fall_time = time.y + time.z;
let fall = -fall_time / 512.0;
return vec2(u, fall + v);
}
// Atlas sampler is nearest with no mipmaps. Clamp the integer texel inside
// the sprite even when a fractional coordinate rounds up at its far edge.
fn weather_texel(uv: vec2<f32>, rect: vec4<f32>, dimensions: vec2<u32>) -> vec2<i32> {
let size = vec2<f32>(dimensions);
let origin = vec2<i32>(round(rect.xy * size));
let extent = max(vec2<i32>(round(rect.zw * size)), vec2(1));
let local = min(vec2<i32>(floor(fract(uv) * vec2<f32>(extent))), extent - vec2(1));
return origin + local;
}
// WEATHER_MATH_END
struct WeatherTime { values: vec4<f32> }
@group(1) @binding(1) var<uniform> weather_time: WeatherTime;
@vertex fn vs_weather(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> Out {
let phase = weather_phase(animation, bases, snow, weather_time.values);
var out = entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
out.weather_rect = rect;
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
var color = textureLoad(atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
File diff suppressed because it is too large. Load diff
@@ -1,863 +0,0 @@
//! Animated block sprites, drawn on the GPU as vanilla 26.3 draws them:
//! `SpriteContents.AnimatedTexture` keeps every frame in video memory, and
//! each tick `animate_sprite_blit` or `animate_sprite_interpolate` draws the
//! frame shown (mixed with the next one for `interpolate`) into the atlas.
//!
//! Every frame's mip chain is uploaded once, at the sprite's own size. A tick
//! that changes sprites writes one small job per changed sprite and runs one
//! compute dispatch per mip level (`animate.wgsl`), which repeats the chain
//! into the array's larger layers by whole pixels and mixes interpolated
//! frames. Blending on the CPU at the array's size and copying every changed
//! sprite level by level (a copy command each, a blit each in Mesa) was about
//! a fifth of the render thread on an integrated GPU.
//!
//! Without compute shaders (OpenGL before 4.3, the last fallback) the same
//! jobs run on the CPU, the same integer mix as `animate.wgsl`, and each
//! changed level is copied into its layer.
use super::upload::Uploads;
use crate::mipmap::Settings;
use crate::sprites::{FrameBlend, SpriteAnimation};
use wgpu::util::DeviceExt;
/// Bytes of `animate.wgsl`'s `Job`.
const JOB_BYTES: usize = 20;
/// `animate.wgsl`'s workgroup edge.
const WORKGROUP: u32 = 8;
struct Sprite {
animation: SpriteAnimation,
/// What its layer shows (`None`: the frame the array was created with,
/// rewritten on the first tick).
shown: Option<FrameBlend>,
/// Its layer in its sprite array.
layer: u32,
/// Each frame's mip chain: its offset in words in the group's frames.
frames: Vec<u32>,
/// Edge of the chains' first level.
edge: u32,
group: usize,
}
/// The animated sprites of one sprite array whose frames share one buffer.
struct Group {
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
offset: u64,
/// One bind group per mip level of the array (none on the CPU).
levels: Vec<wgpu::BindGroup>,
/// This tick's jobs.
jobs: Vec<u8>,
/// Its sprite array and its frames, for the CPU.
array: usize,
buffer: usize,
}
/// Where the jobs run.
enum Writer {
Gpu {
jobs: wgpu::Buffer,
pipeline: wgpu::ComputePipeline,
},
/// Every frames buffer's words, and the sprite arrays to copy into.
Cpu {
frames: Vec<Vec<u8>>,
textures: Vec<wgpu::Texture>,
},
}
pub(super) struct SpriteAnimator {
sprites: Vec<Sprite>,
groups: Vec<Group>,
writer: Writer,
/// Edge of the arrays' layers.
size: u32,
/// The layers the last `animate` rewrote, counted over all arrays.
changed: Vec<u32>,
}
impl SpriteAnimator {
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
/// wide; `animations` hold their frames at that size. `compute`: whether
/// the device says it runs compute shaders writing storage textures.
pub fn new(
device: &wgpu::Device,
animations: &[SpriteAnimation],
size: u32,
settings: &[Settings],
textures: &[wgpu::Texture],
split: u32,
compute: bool,
) -> Self {
let pipeline = if compute { pipeline(device) } else { None };
let limits = device.limits();
let limit = if pipeline.is_some() {
limits
.max_storage_buffer_binding_size
.min(limits.max_buffer_size)
} else {
// Word offsets.
u64::from(u32::MAX) * 4
};
let mut frames: Vec<Vec<u8>> = Vec::new();
// Each group's sprite array and frames buffer.
let mut keys: Vec<(u32, usize)> = Vec::new();
let mut counts: Vec<usize> = Vec::new();
let mut sprites = Vec::with_capacity(animations.len());
for animation in animations {
let pixels: Vec<_> = (0..animation.frame_count())
.map(|frame| animation.pixels(frame))
.collect();
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
if bytes > limit {
log::warn!(
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
animation.layer
);
continue;
}
if frames
.last()
.is_none_or(|data| data.len() as u64 + bytes > limit)
{
frames.push(Vec::new());
}
let buffer = frames.len() - 1;
let data = &mut frames[buffer];
let offsets = chains
.iter()
.map(|chain| {
let offset = (data.len() / 4) as u32;
data.extend_from_slice(chain);
offset
})
.collect();
let key = (animation.layer / split, buffer);
let group = keys.iter().position(|&k| k == key).unwrap_or_else(|| {
keys.push(key);
counts.push(0);
keys.len() - 1
});
counts[group] += 1;
sprites.push(Sprite {
animation: animation.clone(),
shown: None,
layer: animation.layer % split,
frames: offsets,
edge,
group,
});
}
let Some(pipeline) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
.map(|(&(array, buffer), &count)| Group {
offset: 0,
levels: Vec::new(),
jobs: Vec::with_capacity(count * JOB_BYTES),
array: array as usize,
buffer,
})
.collect();
return Self {
sprites,
groups,
writer: Writer::Cpu {
frames,
textures: textures.to_vec(),
},
size,
changed: Vec::new(),
};
};
let frames: Vec<_> = frames
.iter()
.map(|data| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Animated sprite frames"),
contents: data,
usage: wgpu::BufferUsages::STORAGE,
})
})
.collect();
let align = u64::from(limits.min_storage_buffer_offset_alignment);
let mut offsets = Vec::with_capacity(counts.len());
let mut total = 0;
for &count in &counts {
offsets.push(total);
total = (total + (count * JOB_BYTES) as u64).next_multiple_of(align);
}
let jobs = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Animated sprite jobs"),
size: total.max(align),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
.zip(offsets)
.map(|((&(array, buffer), &count), offset)| {
let texture = &textures[array as usize];
let levels = (0..texture.mip_level_count())
.map(|level| {
let view = texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("Animated sprites"),
dimension: Some(wgpu::TextureViewDimension::D2Array),
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Animated sprites"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: frames[buffer].as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &jobs,
offset,
size: wgpu::BufferSize::new((count * JOB_BYTES) as u64),
}),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&view),
},
],
})
})
.collect();
Group {
offset,
levels,
jobs: Vec::with_capacity(count * JOB_BYTES),
array: array as usize,
buffer,
}
})
.collect();
Self {
sprites,
groups,
writer: Writer::Gpu { jobs, pipeline },
size,
changed: Vec::new(),
}
}
/// Whether the jobs can run on the GPU: compute shaders writing a
/// storage texture and reading two storage buffers (not OpenGL before
/// 4.3).
pub fn compute(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
let limits = device.limits();
adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)
&& limits.max_storage_textures_per_shader_stage >= 1
&& limits.max_storage_buffers_per_shader_stage >= 2
}
/// The layers the last `animate` rewrote (for the shader pack atlas).
pub fn changed(&self) -> &[u32] {
&self.changed
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
pub fn animate(
&mut self,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut Uploads,
tick: u64,
) {
if self.collect(tick) == 0 {
return;
}
let (jobs, pipeline) = match &self.writer {
Writer::Gpu { jobs, pipeline } => (jobs, pipeline),
Writer::Cpu { frames, textures } => {
for group in &mut self.groups {
let (frames, texture) = (&frames[group.buffer], &textures[group.array]);
for job in group.jobs.as_chunks::<JOB_BYTES>().0 {
let (words, _) = job.as_chunks::<4>();
let job: [u32; 5] = std::array::from_fn(|i| u32::from_le_bytes(words[i]));
for level in 0..texture.mip_level_count() {
let width = self.size >> level;
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: job[0],
},
aspect: wgpu::TextureAspect::All,
},
width,
width,
&mix_level(frames, job, width),
);
}
}
group.jobs.clear();
}
return;
}
};
for group in &self.groups {
uploads.buffer(encoder, jobs, group.offset, &group.jobs);
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
for group in &mut self.groups {
let count = (group.jobs.len() / JOB_BYTES) as u32;
if count == 0 {
continue;
}
for (level, bind) in group.levels.iter().enumerate() {
let groups = (self.size >> level).div_ceil(WORKGROUP);
pass.set_bind_group(0, bind, &[]);
pass.dispatch_workgroups(groups, groups, count);
}
group.jobs.clear();
}
}
/// Queue a job for every sprite whose blend at `tick` differs from what
/// its layer shows; the number of jobs.
fn collect(&mut self, tick: u64) -> usize {
let mut count = 0;
self.changed.clear();
for sprite in &mut self.sprites {
let blend = sprite.animation.blend(tick);
if sprite.shown == Some(blend) {
continue;
}
sprite.shown = Some(blend);
self.changed.push(sprite.animation.layer);
let job = [
sprite.layer,
sprite.frames[blend.frame],
sprite.frames[blend.next],
blend.millis,
sprite.edge,
];
let jobs = &mut self.groups[sprite.group].jobs;
for word in job {
jobs.extend_from_slice(&word.to_le_bytes());
}
count += 1;
}
count
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Animated sprites"),
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Animated sprites"),
layout: None,
module: &module,
entry_point: Some("animate"),
compilation_options: Default::default(),
cache: None,
});
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
}
}
}
/// `animate.wgsl` on the CPU: one `width` wide level of a job
/// (`[layer, current, next, millis, edge]`) from `frames`' chains.
fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec<u8> {
let mut out = Vec::with_capacity((width * width * 4) as usize);
for y in 0..width {
for x in 0..width {
let texel = if width >= edge {
let scale = width / edge;
y / scale * edge + x / scale
} else {
4 * (edge * edge - width * width) / 3 + y * width + x
};
let at = |chain: u32| ((chain + texel) * 4) as usize;
let a = &frames[at(current)..at(current) + 4];
if millis == 0 {
out.extend_from_slice(a);
continue;
}
let b = &frames[at(next)..at(next) + 4];
out.extend(a.iter().zip(b).map(|(&a, &b)| {
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000) as u8
}));
}
}
out
}
/// The mip chain a frame's layer is made of, and its first level's edge.
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
/// array's size from its own size's chain, its first level repeated into the
/// larger levels: that chain, which the shader repeats. Any other sprite: its
/// chain at the array's size.
#[cfg(test)]
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
(chains.swap_remove(0), edge)
}
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
/// of each level are the sheet's, not each frame's. A frame alone has a
/// single texel square at its 2x2 level, and its coverage scale could round
/// a whole far frame transparent (kelp blinking out at distance).
fn source_chains(
frames: &[&[u8]],
size: u32,
settings: &[Settings],
layer: u32,
) -> (Vec<Vec<u8>>, u32) {
let s = settings.get(layer as usize).copied().unwrap_or_default();
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
s.native_size
} else {
size
};
let mut sheet = crate::mipmap::Image {
width: edge,
height: edge * frames.len() as u32,
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
};
for frame in frames {
let frame = crate::sprites::resample(frame, size, edge);
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
}
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
let levels = crate::mipmap::generate(
sheet,
edge.trailing_zeros(),
Settings {
has_transparent,
..s
},
);
let chains = (0..frames.len())
.map(|frame| {
let mut chain = Vec::new();
for level in &levels {
let texels = (level.width * level.width) as usize;
chain.extend(
level.pixels[frame * texels..(frame + 1) * texels]
.iter()
.flatten(),
);
}
chain
})
.collect();
(chains, edge)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mipmap::Strategy;
/// `animate.wgsl` on the CPU: `chain` (first level `edge` wide) as the
/// full chain of a `size` layer, mixed `millis` thousandths of the way
/// towards `next`.
fn reference(chain: &[u8], next: &[u8], millis: u32, edge: u32, size: u32) -> Vec<u8> {
let levels = size.trailing_zeros() + 1;
let mut out = Vec::new();
for level in 0..levels {
let width = size >> level;
for y in 0..width {
for x in 0..width {
let texel = if width >= edge {
let scale = width / edge;
y / scale * edge + x / scale
} else {
4 * (edge * edge - width * width) / 3 + y * width + x
} as usize;
for c in 0..4 {
let (a, b) = (
u32::from(chain[texel * 4 + c]),
u32::from(next[texel * 4 + c]),
);
out.push(((a * (1000 - millis) + b * millis + 500) / 1000) as u8);
}
}
}
}
out
}
fn frame(size: u32, seed: u32) -> Vec<u8> {
(0..size * size)
.flat_map(|i| {
let v = i
.wrapping_mul(2654435761)
.wrapping_add(seed.wrapping_mul(40503));
let alpha = match v >> 29 {
0 => 0,
1 => 77,
_ => 255,
};
[(v >> 3) as u8, (v >> 11) as u8, (v >> 19) as u8, alpha]
})
.collect()
}
fn settings(native: u32, strategy: Strategy) -> Settings {
Settings {
native_size: native,
strategy,
alpha_cutoff_bias: -0.1,
..Default::default()
}
}
#[test]
fn source_chains_repeat_into_the_array_chain() {
// Sprites scaled up by whole pixels, at the array's size, and of a
// size that is not a power of two (scaled up to the next one).
for (native, size) in [(16, 16), (16, 64), (8, 32), (32, 64), (1, 16), (24, 32)] {
for strategy in [
Strategy::Auto,
Strategy::Mean,
Strategy::Cutout,
Strategy::StrictCutout,
Strategy::DarkCutout,
] {
for item in [false, true] {
let settings = [
Settings::default(),
Settings {
item,
..settings(native, strategy)
},
];
let pixels = crate::sprites::resample(&frame(native, 3), native, size);
let (expected, _) = super::super::mip_chain(&pixels, size, &settings, 1, 1);
let (chain, edge) = source_chain(&pixels, size, &settings, 1);
let fits = native.is_power_of_two() && native < size;
assert_eq!(edge, if fits { native } else { size });
assert_eq!(
reference(&chain, &chain, 0, edge, size),
expected,
"{native} in {size}, {strategy:?}, item {item}"
);
}
}
}
}
#[test]
fn real_animation_chains_repeat_into_the_array_chain() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root
.join("assets/minecraft/textures/block/water_still.png")
.exists()
{
return;
}
let mut sprites = crate::sprites::Sprites::default();
for name in [
"water_still",
"water_flow",
"lava_still",
"fire_0",
"nether_portal",
"sea_lantern",
"prismarine",
"magma",
"kelp",
"straw_bed",
] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
sprites.finish();
assert_eq!(sprites.size, 64);
assert_eq!(sprites.animations.len(), 9);
for animation in &sprites.animations {
for frame in 0..animation.frame_count() {
let pixels = animation.pixels(frame);
let (expected, _) =
super::super::mip_chain(pixels, 64, &sprites.mips, animation.layer, 1);
let (chain, edge) = source_chain(pixels, 64, &sprites.mips, animation.layer);
assert!(edge < 64, "layer {}", animation.layer);
assert_eq!(reference(&chain, &chain, 0, edge, 64), expected);
}
}
}
#[test]
fn kelp_frames_share_the_sheet_mips() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root
.join("assets/minecraft/textures/block/kelp.png")
.exists()
{
return;
}
let mut sprites = crate::sprites::Sprites::default();
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
sprites.finish();
// A level drawn nowhere: no texel passes the cutout's alpha test.
let blank = |chain: &[u8], edge: u32| {
let mut start = 0;
let mut level = edge;
let mut blank = Vec::new();
while level > 0 {
let bytes = (level * level * 4) as usize;
let texels = &chain[start..start + bytes];
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
start += bytes;
level >>= 1;
}
blank
};
let mut blinked = 0;
for animation in &sprites.animations {
let size = sprites.size;
let pixels: Vec<_> = (0..animation.frame_count())
.map(|f| animation.pixels(f))
.collect();
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
for (frame, chain) in chains.iter().enumerate() {
// The 1x1 level has no texel square to measure coverage on
// (vanilla leaves it unscaled); every larger level shows.
let levels = blank(chain, edge);
assert!(
!levels[..levels.len() - 1].contains(&true),
"layer {} frame {frame}: {levels:?}",
animation.layer
);
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
let alone = blank(&alone, edge);
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
}
}
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
assert!(blinked > 0);
}
#[test]
fn rounding_matches_f32_mix_but_for_exact_halves() {
// The CPU blend this replaced: f32 mix, then rounded. They differ
// only where the exact mix is a half, which f32 rounded either way.
for millis in 1..1000u32 {
let t = millis as f32 / 1000.0;
for a in 0..=255u32 {
for b in (0..=255u32).step_by(3) {
let f = ((a as f32 / 255.0) * (1.0 - t) + (b as f32 / 255.0) * t) * 255.0;
let exact = a * (1000 - millis) + b * millis;
let ours = (exact + 500) / 1000;
if exact % 1000 != 500 {
assert_eq!(ours, f.round() as u32, "{a} {b} {millis}");
}
}
}
}
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Every layer's full mip chain, layer major.
fn read_back(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
) -> Vec<Vec<u8>> {
let layers = texture.depth_or_array_layers();
let mut out = vec![Vec::new(); layers as usize];
for level in 0..texture.mip_level_count() {
let edge = texture.width() >> level;
let padded = (edge * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(padded * edge * layers),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: level,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(edge),
},
},
wgpu::Extent3d {
width: edge,
height: edge,
depth_or_array_layers: layers,
},
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap();
for (layer, image) in data.chunks((padded * edge) as usize).enumerate() {
for row in image.chunks(padded as usize) {
out[layer].extend_from_slice(&row[..edge as usize * 4]);
}
}
}
out
}
#[test]
fn gpu_layers_match_the_cpu_chains_every_tick() {
layers_match_the_cpu_chains_every_tick(true);
}
/// Without compute shaders (OpenGL before 4.3): the same layers.
#[test]
fn copied_layers_match_the_cpu_chains_every_tick() {
layers_match_the_cpu_chains_every_tick(false);
}
fn layers_match_the_cpu_chains_every_tick(compute: bool) {
let Some((device, queue)) = device() else {
return;
};
let size = 32;
// Layers 1 and 3: 8-pixel sprites with and without interpolation;
// layer 4: one at the array's size. Two layers per array.
let clips = [
(
1,
8,
r#"{"frametime":3,"interpolate":true,"frames":[0,2,1]}"#,
),
(3, 8, r#"{"frametime":2}"#),
(4, 32, r#"{"frametime":5,"interpolate":true}"#),
];
let mut mips = vec![Settings::default(); 6];
let animations: Vec<_> = clips
.iter()
.map(|&(layer, native, metadata)| {
mips[layer as usize] = settings(native, Strategy::Cutout);
let frames = (0..3)
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
.collect();
crate::sprites::test_frames(layer, frames, metadata)
})
.collect();
let split = 2;
let textures: Vec<_> = (0..3)
.map(|_| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: split,
},
mip_level_count: size.trailing_zeros() + 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
})
.collect();
let mut animator =
SpriteAnimator::new(&device, &animations, size, &mips, &textures, split, compute);
let mut uploads = Uploads::new(&device);
let mut writes = Vec::new();
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick);
writes.push(
animator
.sprites
.iter()
.zip(shown)
.filter(|(s, shown)| s.shown != *shown)
.count(),
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for animation in &animations {
let blend = animation.blend(tick);
let pixels: Vec<_> = (0..animation.frame_count())
.map(|f| animation.pixels(f))
.collect();
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
let layers = read_back(
&device,
&queue,
&textures[(animation.layer / split) as usize],
);
assert_eq!(
layers[(animation.layer % split) as usize],
reference(current, next, blend.millis, edge, size),
"layer {} at tick {tick}",
animation.layer
);
}
}
// Every sprite on the first tick, then only those whose frame or mix
// changed since they were written: the interpolated ones every tick
// but layer 1 at tick 30 (two 9-tick loops after 12), layer 3 when
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
}
}
@@ -1,48 +0,0 @@
// Animated block sprites (gfx/animate.rs), as vanilla's animate_sprite_blit
// and animate_sprite_interpolate shaders draw them: each job writes its
// frame, or the frame mixed with the next one, into one mip level of its
// sprite array layer. One invocation per texel; the job is the z coordinate.
struct Job {
layer: u32,
// Word offsets of the two frames' mip chains in `frames`.
current: u32,
next: u32,
// Thousandths of the way from `current` to `next` (0: `current` alone).
millis: u32,
// Edge of the chains' first level.
edge: u32,
}
@group(0) @binding(0) var<storage, read> frames: array<u32>;
@group(0) @binding(1) var<storage, read> jobs: array<Job>;
@group(0) @binding(2) var level: texture_storage_2d_array<rgba8unorm, write>;
fn channels(rgba: u32) -> vec4<u32> {
return vec4(rgba & 0xffu, (rgba >> 8u) & 0xffu, (rgba >> 16u) & 0xffu, rgba >> 24u);
}
@compute @workgroup_size(8, 8)
fn animate(@builtin(global_invocation_id) id: vec3<u32>) {
let size = textureDimensions(level).x;
if id.x >= size || id.y >= size {
return;
}
let job = jobs[id.z];
// mipmap::generate: the levels at least as large as the chain's first
// repeat it by whole pixels, the smaller ones are the chain's own.
var texel: u32;
if size >= job.edge {
let scale = size / job.edge;
texel = id.y / scale * job.edge + id.x / scale;
} else {
// Every level above this one, each a quarter of the one before.
texel = 4u * (job.edge * job.edge - size * size) / 3u + id.y * size + id.x;
}
var color = channels(frames[job.current + texel]);
if job.millis > 0u {
// mix(current, next, millis / 1000), rounded to the nearest 8-bit
// value (halves up) in integers: the same on every GPU.
let next = channels(frames[job.next + texel]);
color = (color * (1000u - job.millis) + next * job.millis + 500u) / 1000u;
}
textureStore(level, id.xy, job.layer, vec4<f32>(color) / 255.0);
}
@@ -1,191 +0,0 @@
use wgpu::util::DeviceExt;
/// Sample the production WGSL with the production nearest/no-mipmap atlas sampler.
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn crack_samples_stay_aligned_between_stages() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
eprintln!("crack sampling adapter: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap();
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
uv: [f32; 2],
stage: u32,
extent: u32,
}
let mut cases = Vec::new();
let mut expected = Vec::<[u32; 2]>::new();
// Include exact texel boundaries, their neighbours, negative projected UVs,
// wrapping and the far sprite edge. Origins straddle the packing error's sign change.
for extent in [16, 32] {
for x in [32, 2080, 4064] {
for y in [0, 32] {
for i in -32..=32 {
for delta in [-0.0001, 0.0, 0.0001] {
let uv = [i as f32 / 16.0 + delta, 0.5 - delta];
cases.push(Case {
uv,
stage: crate::breaking::stage_light([
x as f32 / 4096.0,
y as f32 / 64.0,
]),
extent,
});
expected.push(uv.map(|v| {
(((v - v.floor()) * extent as f32).floor() as u32).min(extent - 1)
}));
}
}
}
}
}
let math = include_str!("../entity.wgsl")
.split("// CRUMBLING_UV_BEGIN")
.nth(1)
.unwrap()
.split("// CRUMBLING_UV_END")
.next()
.unwrap();
let source = format!(
"{math}\n{}",
r#"
struct Case { uv: vec2<f32>, stage: u32, extent: u32 }
@group(0) @binding(0) var<storage, read> cases: array<Case>;
@group(0) @binding(1) var<storage, read_write> results: array<vec4<u32>>;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if id.x >= arrayLength(&cases) { return; }
let c = cases[id.x];
let origin = vec2(f32(c.stage & 65535u), f32(c.stage >> 16u)) / 65535.0;
let dimensions = textureDimensions(atlas);
let uv = crumbling_uv(c.uv, origin, vec2(f32(c.extent)) / vec2<f32>(dimensions), dimensions);
// Keep the old addressing as a control: the fixture must reproduce its stage jitter.
let old_uv = origin + fract(c.uv) * vec2(f32(c.extent)) / vec2<f32>(dimensions);
results[id.x] = vec4<u32>(
vec2<u32>(round(textureSampleLevel(atlas, atlas_sampler, uv, 0.0).rg * 255.0)),
vec2<u32>(round(textureSampleLevel(atlas, atlas_sampler, old_uv, 0.0).rg * 255.0)));
}"#
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Crack sampling regression"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
// Every sprite repeats the same coordinate pattern, so moving a stage's
// atlas origin must leave every sampled local texel unchanged.
let pixels: Vec<u8> = (0..64)
.flat_map(|y| (0..4096).flat_map(move |x| [(x % 32) as u8, (y % 32) as u8, 0, 255]))
.collect();
let atlas = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4096,
height: 64,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
);
let view = atlas.create_view(&Default::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default());
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&cases),
usage: wgpu::BufferUsages::STORAGE,
});
let size = (expected.len() * 16) as u64;
let output = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: input.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: output.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups((cases.len() as u32).div_ceil(64), 1, 1);
}
encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, size);
queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
readback.map_async(wgpu::MapMode::Read, .., move |r| tx.send(r).unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let mapped = readback.get_mapped_range(..).unwrap();
let actual: &[[u32; 4]] = bytemuck::cast_slice(&mapped);
for (i, (actual, expected)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(
&actual[..2],
expected,
"case {i}, uv={:?}, stage={}, extent={}",
cases[i].uv,
cases[i].stage,
cases[i].extent
);
}
let old_mismatches = actual
.iter()
.zip(&expected)
.filter(|(a, e)| &a[2..] != e.as_slice())
.count();
assert!(
old_mismatches > 0,
"fixture must detect the original sampling bug"
);
eprintln!(
"{} crack texture samples matched; old addressing mismatched {old_mismatches}",
cases.len()
);
}
@@ -1,86 +0,0 @@
//! The End sky's six tiled faces, retained for the lifetime of the renderer's atlas.
use crate::mesher::Vertex;
use wgpu::util::DeviceExt;
const VERTICES: u32 = 6 * 16 * 16 * 4;
#[derive(Default)]
pub(super) struct EndSky {
mesh: Option<([f32; 4], wgpu::Buffer)>,
}
impl EndSky {
/// Called only when the End sky is enabled. Rebuild on atlas UV changes;
/// resource reload and device recreation also replace the owning renderer.
pub(super) fn prepare(&mut self, device: &wgpu::Device, uv: Option<[f32; 4]>) {
let Some(uv) = uv else {
self.mesh = None;
return;
};
if self.mesh.as_ref().is_some_and(|(old, _)| *old == uv) {
return;
}
let vertices = vertices(uv);
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("End sky vertices"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
self.mesh = Some((uv, buffer));
}
/// The celestial pipeline, atlas bind group and quad indices are already bound.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some((_, buffer)) = &self.mesh {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw_indexed(0..VERTICES / 4 * 6, 0, 0..1);
}
}
}
/// SkyRenderer.buildEndSky: six faces of end_sky.png tiled 16 times,
/// tinted 0x282828. The atlas does not wrap, so each face is 16×16 quads.
fn vertices(uv: [f32; 4]) -> Vec<Vertex> {
let mut out = Vec::with_capacity(VERTICES as usize);
use std::f32::consts::FRAC_PI_2;
let faces = [
glam::Mat4::IDENTITY,
glam::Mat4::from_rotation_x(FRAC_PI_2),
glam::Mat4::from_rotation_x(-FRAC_PI_2),
glam::Mat4::from_rotation_x(std::f32::consts::PI),
glam::Mat4::from_rotation_z(FRAC_PI_2),
glam::Mat4::from_rotation_z(-FRAC_PI_2),
];
let step = 200.0 / 16.0;
for pose in faces {
for a in 0..16 {
for b in 0..16 {
let (x0, z0) = (-100.0 + step * a as f32, -100.0 + step * b as f32);
let (x1, z1) = (x0 + step, z0 + step);
let corners = [
(x0, z0, [uv[0], uv[1]]),
(x0, z1, [uv[0], uv[3]]),
(x1, z1, [uv[2], uv[3]]),
(x1, z0, [uv[2], uv[1]]),
];
for (x, z, uv) in corners {
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 2,
color: 0xFF28_2828,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
out
}
#[cfg(test)]
mod tests;
@@ -1,330 +0,0 @@
use super::*;
use crate::gfx::{Globals, Renderer};
use bytemuck::Zeroable;
use glam::Mat4;
#[test]
fn geometry_matches_pre_residency_vertex_snapshots() {
use sha2::{Digest, Sha256};
// Captured from game/end.rs at 34c3c1cf for full, tiled and non-binary UVs.
// Quantize positions only, avoiding platform sin/cos rounding differences;
// quad order, UVs, tint, material and light retain their exact byte values.
for (uv, expected) in [
(
[0.0, 0.0, 1.0, 1.0],
"82a9be3f3ed1f733017df2c81dc6dff163f67bf739c768e3fd0f90f00c7802f6",
),
(
[0.125, 0.25, 0.375, 0.5],
"05a058bd8e70cff900c8e1c81061892ed27866d86498ebe1e8ae5af4c3b770ef",
),
(
[0.13, 0.27, 0.41, 0.59],
"93064b9fd2e745da46b927b2625edc8a381bb8c6e20b0f0880e816c4c1745ec1",
),
] {
let mut mesh = vertices(uv);
assert_eq!(mesh.len(), VERTICES as usize);
for vertex in &mut mesh {
vertex.pos = vertex.pos.map(|x| {
let n = (x * 1024.0).round() / 1024.0;
if n == 0.0 { 0.0 } else { n }
});
}
let hash = format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh)));
assert_eq!(hash, expected);
}
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("End sky test adapter: {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn resident_buffer_survives_frames_and_refreshes_with_the_atlas() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut sky = EndSky::default();
let uv = [0.125, 0.25, 0.375, 0.5];
sky.prepare(&device, Some(uv));
let first = sky.mesh.as_ref().unwrap().1.clone();
assert_eq!(
first.size(),
u64::from(VERTICES) * size_of::<Vertex>() as u64
);
// Immutable: steady-state frames cannot upload into this buffer.
assert_eq!(first.usage(), wgpu::BufferUsages::VERTEX);
for _ in 0..600 {
sky.prepare(&device, Some(uv));
assert_eq!(sky.mesh.as_ref().unwrap().1, first);
}
let moved = [0.5, 0.5, 0.75, 0.75];
sky.prepare(&device, Some(moved));
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
assert_eq!(sky.mesh.as_ref().unwrap().0, moved);
sky.prepare(&device, None);
assert!(sky.mesh.is_none());
sky.prepare(&device, Some(uv));
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
// A resource reload/device recreation starts a new renderer-owned cache,
// even if the replacement atlas happens to use exactly the same UVs.
let mut replacement = EndSky::default();
replacement.prepare(&device, Some(uv));
assert_ne!(
replacement.mesh.as_ref().unwrap().1,
sky.mesh.as_ref().unwrap().1
);
}
/// The actual entity shader and celestial blend, comparing the old combined
/// vertex stream with a resident sky draw followed by a separate flash draw.
#[test]
fn separate_draws_match_combined_celestial_pixels() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("End sky differential shader"),
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("End sky differential pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let texture = |pixels: &[u8], width, height| {
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
pixels,
)
};
// Asymmetric, translucent texels expose UV/winding and blend-order errors.
let pixels: Vec<u8> = (0..16 * 16)
.flat_map(|i| {
[
(i * 17) as u8,
(i * 31) as u8,
(i * 7) as u8,
(80 + i % 176) as u8,
]
})
.collect();
let atlas = texture(&pixels, 16, 16).create_view(&Default::default());
let lightmap = texture(&[255; 4], 1, 1).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&atlas),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let indices = Renderer::quad_indices(&device, VERTICES / 4 + 1);
let mut resident = EndSky::default();
let uv = [0.125, 0.25, 0.375, 0.5];
resident.prepare(&device, Some(uv));
let first = resident.mesh.as_ref().unwrap().1.clone();
let render = |sky: &EndSky, flash: &[Vertex], combined: bool| {
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 64,
height: 64,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut vertices = if combined { vertices(uv) } else { Vec::new() };
vertices.extend_from_slice(flash);
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 64 * 64 * 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if !combined {
sky.draw(&mut pass);
}
if !vertices.is_empty() {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw_indexed(0..vertices.len() as u32 / 4 * 6, 0, 0..1);
}
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(64),
},
},
target.size(),
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
readback.slice(..).get_mapped_range().unwrap().to_vec()
};
let mut cases = 0;
let mut visible_flash_cases = 0;
for yaw in [0.0_f32, 45.0, 90.0, 180.0, 270.0] {
for pitch in [-85.0_f32, -30.0, 0.0, 45.0, 85.0] {
let mut globals = Globals::zeroed();
globals.view_proj = (Mat4::perspective_rh(90_f32.to_radians(), 1.0, 0.05, 1024.0)
* Mat4::from_rotation_x(pitch.to_radians())
* Mat4::from_rotation_y(yaw.to_radians()))
.to_cols_array_2d();
queue.write_buffer(&uniform, 0, bytemuck::bytes_of(&globals));
let no_flash = render(&resident, &[], true);
for level in [0_u32, 1, 127, 255] {
let flash: Vec<_> = if level == 0 {
Vec::new()
} else {
// Same flash size/height, with an arbitrary EndFlashState pose.
let pose = Mat4::from_rotation_y(1.3) * Mat4::from_rotation_x(-1.6);
[(-60.0, -60.0), (60.0, -60.0), (60.0, 60.0), (-60.0, 60.0)]
.map(|(x, z)| Vertex {
pos: pose.transform_point3(glam::vec3(x, 100.0, z)).to_array(),
uv: [0.75 + x / 480.0, 0.75 + z / 480.0],
layer: 2,
color: level * 0x01010101,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
.to_vec()
};
resident.prepare(&device, Some(uv));
let expected = render(&resident, &flash, true);
let actual = render(&resident, &flash, false);
visible_flash_cases += usize::from(expected != no_flash);
assert_eq!(actual, expected, "yaw={yaw} pitch={pitch} flash={level}");
assert!(
actual.as_chunks::<4>().0.iter().any(|p| p[..3] != [0; 3]),
"empty frame"
);
assert_eq!(resident.mesh.as_ref().unwrap().1, first);
cases += 1;
}
}
}
assert!(
visible_flash_cases > 0,
"the flash must affect the comparison"
);
eprintln!("{cases} combined/resident framebuffer cases matched exactly");
}
@@ -1,311 +0,0 @@
//! The entity outline (`LevelRenderer.entityOutlineTarget`): the glowing entities'
//! silhouettes in their team colours, drawn without depth so walls do not hide them
//! (`RenderTypes.outline`), the `entity_outline` post chain over them (`entity_sobel`, then
//! `entity_outline_box_blur` across and up), and `LevelRenderer.blitEntityOutline` onto
//! the frame after the hand.
use super::Vertex;
/// `entityOutlineTarget`'s `GpuFormat.RGBA8_UNORM`.
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
struct Targets {
size: [u32; 2],
/// `entityOutlineTarget`.
outline: wgpu::TextureView,
/// The chain's `swap` target.
swap: wgpu::TextureView,
/// What each pass reads: the outline (nearest), swap (bilinear), the outline
/// (bilinear), swap (nearest).
groups: [wgpu::BindGroup; 4],
}
pub(super) struct EntityOutline {
silhouette: wgpu::RenderPipeline,
/// Sobel, blur across, blur up (into `FORMAT`) and the blended blit (onto the frame).
passes: [wgpu::RenderPipeline; 4],
layout: wgpu::BindGroupLayout,
nearest: wgpu::Sampler,
bilinear: wgpu::Sampler,
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
targets: Option<Targets>,
}
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
pub(super) fn new(
device: &wgpu::Device,
entity: (
&wgpu::ShaderModule,
&wgpu::PipelineLayout,
&[Option<wgpu::VertexBufferLayout>],
),
format: wgpu::TextureFormat,
) -> Self {
let (entity_shader, entity_layout, entity_vertex) = entity;
// RenderPipelines.OUTLINE_NO_CULL: no depth test, the colour replaced.
let silhouette = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Entity outline silhouettes"),
layout: Some(entity_layout),
vertex: wgpu::VertexState {
module: entity_shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: entity_vertex,
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: entity_shader,
entry_point: Some("fs_outline"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity outline"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_outline.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity outline bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity outline"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pass = |entry: &str, target: wgpu::ColorTargetState| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_quad"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(target)],
}),
multiview_mask: None,
cache: None,
})
};
// BlendFunction.ENTITY_OUTLINE_BLIT over the colour only (write mask 7).
let blit = wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::COLOR,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Self {
silhouette,
passes: [
pass("fs_sobel", FORMAT.into()),
pass("fs_blur_x", FORMAT.into()),
pass("fs_blur_y", FORMAT.into()),
pass("fs_blit", blit),
],
layout,
nearest: sampler("Entity outline nearest", wgpu::FilterMode::Nearest),
bilinear: sampler("Entity outline bilinear", wgpu::FilterMode::Linear),
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
targets: None,
}
}
fn vertex_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity outline vertices"),
size: capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
return;
}
let vertices = silhouettes.len() as u64;
if vertices > self.capacity {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
let texture = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let outline = texture("Entity outline");
let swap = texture("Entity outline swap");
let group = |view: &wgpu::TextureView, sampler: &wgpu::Sampler| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity outline pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
};
let groups = [
group(&outline, &self.nearest),
group(&swap, &self.bilinear),
group(&outline, &self.bilinear),
group(&swap, &self.nearest),
];
self.targets = Some(Targets {
size,
outline,
swap,
groups,
});
}
/// `LevelRenderer.executeOutline`, the post chain and `blitEntityOutline` onto `frame`.
pub(super) fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
let steps = [
("Entity outline sobel", &t.swap, clear),
("Entity outline blur across", &t.outline, clear),
("Entity outline blur up", &t.swap, clear),
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
}
}
}
fn begin<'e>(
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
..Default::default()
})
}
@@ -1,60 +0,0 @@
// The entity_outline post chain (post_effect/entity_outline.json) over the silhouettes of
// the glowing entities, then RenderTarget.blitAndBlendToTexture onto the frame.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// screenquad.vsh, with v running down the rows as wgpu's textures do.
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn tap(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(source, source_sampler, uv, 0.0);
}
// entity_sobel.fsh: alpha marks where coverage changes, the colour is the mean of the
// covered texels around.
@fragment fn fs_sobel(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let center = tap(in.uv);
let left = tap(in.uv - vec2(texel.x, 0.0));
let right = tap(in.uv + vec2(texel.x, 0.0));
let up = tap(in.uv - vec2(0.0, texel.y));
let down = tap(in.uv + vec2(0.0, texel.y));
let total = clamp(abs(center.a - left.a) + abs(center.a - right.a)
+ abs(center.a - up.a) + abs(center.a - down.a), 0.0, 1.0);
let color = center.rgb * center.a + left.rgb * left.a + right.rgb * right.a
+ up.rgb * up.a + down.rgb * down.a;
return vec4(color * 0.2, total);
}
// entity_outline_box_blur.fsh at Radius 2: bilinear taps at -1.5 and 0.5 texels and half
// a tap at 2; the colour averaged, the alpha summed.
fn blur(uv: vec2<f32>, step: vec2<f32>) -> vec4<f32> {
let blurred = tap(uv + step * -1.5) + tap(uv + step * 0.5) + tap(uv + step * 2.0) / 2.0;
return vec4(blurred.rgb / 2.5, blurred.a);
}
// BlurDir (1, 0).
@fragment fn fs_blur_x(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(1.0 / f32(textureDimensions(source).x), 0.0));
}
// BlurDir (0, 1): up the screen in GL's bottom-up textures, towards row 0 here.
@fragment fn fs_blur_y(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(0.0, -1.0 / f32(textureDimensions(source).y)));
}
// blit_screen.fsh, blended by RenderPipelines.ENTITY_OUTLINE_BLIT.
@fragment fn fs_blit(in: PostOut) -> @location(0) vec4<f32> {
return tap(in.uv);
}
File diff suppressed because it is too large. Load diff
@@ -1,76 +0,0 @@
// Flat section draws (gfx/flat.rs): copies each section draw's indices into
// one index list, in draw order, offset by the draw's base vertex. Solid
// draws index quads (0 1 2 0 2 3 per quad, as Renderer::quad_indices);
// translucent ones read their sorted indices.
struct Range {
count: u32,
// First sorted index, or QUADS for a solid draw (`flatten_seen`: its
// entry in occlusion culling's phase 2 draws).
first: u32,
base: u32,
// First index in the list (`flatten_seen`: its draw call).
dst: u32,
}
const QUADS: u32 = 0xffffffffu;
const GROUP: u32 = 64u;
@group(0) @binding(0) var<storage, read> ranges: array<Range>;
@group(0) @binding(1) var<storage, read> sorted: array<u32>;
@group(0) @binding(2) var<storage, read_write> out: array<u32>;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
@compute @workgroup_size(64)
fn flatten(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = group.y * 65535u + group.x;
if r >= arrayLength(&ranges) {
return;
}
let d = ranges[r];
for (var k = lane; k < d.count; k += GROUP) {
var i: u32;
if d.first == QUADS {
i = quad(k);
} else {
i = sorted[d.first + k];
}
out[d.dst + k] = d.base + i;
}
}
// Occlusion culling's phase 2 (gfx/cull.rs), after its test: the draws as
// the test left them (`DrawIndexedIndirectArgs`, the instance count 1 where
// it saw the section), and phase 2's draw calls, whose index counts start
// at 0. A draw the test saw takes the next indices of its call's run.
@group(0) @binding(3) var<storage, read> draws: array<u32>;
@group(0) @binding(4) var<storage, read_write> calls: array<atomic<u32>>;
const UNSEEN: u32 = 0xffffffffu;
var<workgroup> start: u32;
@compute @workgroup_size(64)
fn flatten_seen(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = group.y * 65535u + group.x;
if r >= arrayLength(&ranges) {
return;
}
let d = ranges[r];
if lane == 0u {
var s = UNSEEN;
if draws[d.first * 5u + 1u] != 0u {
s = atomicLoad(&calls[d.dst * 5u + 2u]) + atomicAdd(&calls[d.dst * 5u], d.count);
}
start = s;
}
let s = workgroupUniformLoad(&start);
if s == UNSEEN {
return;
}
for (var k = lane; k < d.count; k += GROUP) {
out[s + k] = d.base + quad(k);
}
}
-301
View File
@@ -1,301 +0,0 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
//! white, expanding the world's highlights towards their peak brightness. The
//! world is copied aside before the hand and HUD so only it is expanded.
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Output {
/// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal.
Pq,
/// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits.
ScRgb,
}
impl Output {
pub fn name(self) -> &'static str {
match self {
Self::Pq => "HDR10 (PQ)",
Self::ScRgb => "scRGB",
}
}
pub fn format(self) -> wgpu::TextureFormat {
match self {
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
Self::ScRgb => wgpu::TextureFormat::Rgba16Float,
}
}
pub fn color_space(self) -> wgpu::SurfaceColorSpace {
match self {
Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq,
Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear,
}
}
fn space(self) -> wgpu::SurfaceColorSpaces {
match self {
Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ,
Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR,
}
}
}
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
world: wgpu::Texture,
group: wgpu::BindGroup,
}
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
debug: bool,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
}
impl Hdr {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &[
texture(0),
texture(1),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: output.format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
layout,
pipeline,
params,
output,
format,
targets: None,
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
paper_white: 200.0,
peak: 1000.0,
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
};
let frame = texture(
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.params.as_entire_binding(),
},
],
});
self.targets = Some(Targets {
size,
frame,
world,
group,
});
}
self.targets.as_ref().unwrap().frame.clone()
}
/// Keeps the world as drawn so far, before the hand and HUD cover it.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
};
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
t.frame.size(),
);
}
/// Writes the finished frame to the swapchain image `target`.
pub fn present(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
) {
let Some(t) = &self.targets else {
return;
};
let paper = self.paper_white.max(1.0);
let params = [
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
0.0,
0.0,
0.0,
];
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &t.group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
wgpu::SurfaceCapabilities {
format_capabilities: offered
.iter()
.map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities {
format,
color_spaces,
})
.collect(),
..Default::default()
}
}
#[test]
fn hdr10_first_then_scrgb() {
let both = caps(&[
(F::Bgra8UnormSrgb, S::SRGB),
(F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG),
(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR),
]);
assert_eq!(pick(&both, None), Some(Output::Pq));
assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb));
let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]);
assert_eq!(pick(&scrgb, None), Some(Output::ScRgb));
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
@@ -1,213 +0,0 @@
//! Seeded, bolt-local lightning meshes retained until their last active bolt disappears.
use crate::{gfx::upload::Uploads, mesher::Vertex};
use glam::DVec3;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::collections::HashMap;
use wgpu::util::DeviceExt;
/// A bolt's render state. Simulation lifetime and flashes stay in the game.
#[derive(Clone, Copy)]
pub struct Bolt {
pub at: [f64; 3],
pub seed: i64,
}
#[derive(Default)]
pub struct Input<'a> {
pub bolts: &'a [Bolt],
/// Current atlas white texel; a changed rect invalidates retained vertices.
pub uv: [f32; 2],
}
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
pub(super) const QUADS: u32 = 4 * 14 * 4;
/// `LightningBoltRenderer.submit`: keep random draws, layers and quad order unchanged.
fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
let mut out = Vec::with_capacity(QUADS as usize * 4);
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
let mut random = LegacyRandom::new(seed);
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
let (mut x_off, mut z_off) = (0f32, 0f32);
for i in (0..8).rev() {
xs[i] = x_off;
zs[i] = z_off;
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
}
for layer in 0..4 {
let mut random = LegacyRandom::new(seed);
for branch in 0..3 {
let trunk = branch == 0;
let start = 7 - branch;
let end = if trunk { 0 } else { start - 3 + 1 };
let mut sx = xs[start] - x_off;
let mut sz = zs[start] - z_off;
for segment in (end..=start).rev() {
let (ex, ez) = (sx, sz);
let spread = if trunk { 11 } else { 31 };
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
let mut top = 0.1 + layer as f32 * 0.2;
let mut bottom = top;
if trunk {
top *= segment as f32 * 0.1 + 1.0;
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
}
for (rx, rz, lx, lz) in [
(false, false, true, false),
(true, false, true, true),
(true, true, false, true),
(false, true, false, false),
] {
let s = |p: bool, r: f32| if p { r } else { -r };
let y0 = segment as f32 * 16.0;
let corners = [
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
];
for c in corners {
out.push(Vertex {
normal: [0; 4],
pos: c,
uv,
layer: 2,
color,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
}
}
}
out
}
struct Cached<T> {
mesh: T,
used: bool,
}
/// Shared seeds use the same mesh; storage is bounded by active unique seeds,
/// including after seed changes, removals, dimension switches and disconnects.
struct Cache<T> {
uv: Option<[f32; 2]>,
meshes: HashMap<i64, Cached<T>>,
}
impl<T> Cache<T> {
fn new() -> Self {
Self {
uv: None,
meshes: HashMap::new(),
}
}
fn prepare(&mut self, input: &Input<'_>, mut build: impl FnMut(i64, [f32; 2]) -> T) {
if self.uv != Some(input.uv) {
self.meshes.clear();
self.uv = Some(input.uv);
}
for cached in self.meshes.values_mut() {
cached.used = false;
}
for bolt in input.bolts {
self.meshes
.entry(bolt.seed)
.or_insert_with(|| Cached {
mesh: build(bolt.seed, input.uv),
used: true,
})
.used = true;
}
self.meshes.retain(|_, cached| cached.used);
// HashMap::retain keeps its allocation. Release historical peak capacity
// as well as meshes once the storm (or connection) is gone.
if self.meshes.is_empty() {
self.meshes = HashMap::new();
} else if self.meshes.capacity() > self.meshes.len() * 4 {
self.meshes.shrink_to_fit();
}
}
}
fn offset(bolt: &Bolt, camera: DVec3) -> [f32; 3] {
(DVec3::from_array(bolt.at) - camera).as_vec3().to_array()
}
pub(super) struct Renderer {
cache: Cache<wgpu::Buffer>,
instances: Option<wgpu::Buffer>,
capacity: usize,
}
impl Renderer {
pub(super) fn new() -> Self {
Self {
cache: Cache::new(),
instances: None,
capacity: 0,
}
}
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &Input<'_>,
camera: DVec3,
) {
self.cache.prepare(input, |seed, uv| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Lightning vertices"),
contents: bytemuck::cast_slice(&geometry(seed, uv)),
usage: wgpu::BufferUsages::VERTEX,
})
});
if input.bolts.is_empty() {
self.instances = None;
self.capacity = 0;
return;
}
if input.bolts.len() > self.capacity || input.bolts.len() < self.capacity / 4 {
self.capacity = input.bolts.len().next_power_of_two();
self.instances = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Lightning offsets"),
size: (self.capacity * std::mem::size_of::<[f32; 3]>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
let offsets: Vec<_> = input
.bolts
.iter()
.map(|bolt| offset(bolt, camera))
.collect();
uploads.buffer(
encoder,
self.instances.as_ref().unwrap(),
0,
bytemuck::cast_slice(&offsets),
);
}
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, input: &Input<'_>) {
let Some(instances) = &self.instances else {
return;
};
pass.set_vertex_buffer(1, instances.slice(..));
// Follow simulation order, including repeated seeds; HashMap order must
// never change additive accumulation or the order before dragon rays.
for (i, bolt) in input.bolts.iter().enumerate() {
pass.set_vertex_buffer(0, self.cache.meshes[&bolt.seed].mesh.slice(..));
pass.draw_indexed(0..QUADS * 6, 0, i as u32..i as u32 + 1);
}
}
}
#[cfg(test)]
mod tests;
@@ -1,498 +0,0 @@
use super::*;
use std::cell::Cell;
// Frozen pre-cache generator: compare every Vertex byte, including primitive
// order and the original camera-relative rounding, without game resources.
fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex> {
let mut out = Vec::new();
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
let b = Bolt { seed, at };
{
let o = (glam::DVec3::from_array(b.at) - camera).as_vec3();
let mut random = LegacyRandom::new(b.seed);
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
let (mut x_off, mut z_off) = (0f32, 0f32);
for i in (0..8).rev() {
xs[i] = x_off;
zs[i] = z_off;
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
}
for layer in 0..4 {
let mut random = LegacyRandom::new(b.seed);
for branch in 0..3 {
let trunk = branch == 0;
let start = 7 - branch;
let end = if trunk { 0 } else { start - 3 + 1 };
let mut sx = xs[start] - x_off;
let mut sz = zs[start] - z_off;
for segment in (end..=start).rev() {
let (ex, ez) = (sx, sz);
let spread = if trunk { 11 } else { 31 };
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
let mut top = 0.1 + layer as f32 * 0.2;
let mut bottom = top;
if trunk {
top *= segment as f32 * 0.1 + 1.0;
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
}
for (rx, rz, lx, lz) in [
(false, false, true, false),
(true, false, true, true),
(true, true, false, true),
(false, true, false, false),
] {
let s = |p: bool, r: f32| if p { r } else { -r };
let y0 = segment as f32 * 16.0;
let corners = [
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
];
for c in corners {
out.push(Vertex {
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
layer: 2,
color,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
}
}
}
}
out
}
fn seeds() -> Vec<i64> {
let mut seeds = vec![0, 1, -1, i64::MIN, i64::MAX, 0x123456789abcdef];
let mut random = LegacyRandom::new(42);
seeds.extend((0..256).map(|_| random.next_long()));
seeds
}
/// Pinned 26.3 LightningBoltRenderer.lambda$submit$0, captured with an
/// identity PoseStack and a VertexConsumer recording big-endian xyz floats.
#[test]
fn geometry_matches_pinned_vanilla_vertex_digest() {
use sha2::{Digest, Sha256};
let mut digest = Sha256::new();
for seed in seeds() {
for vertex in geometry(seed, [0.0; 2]) {
for value in vertex.pos {
digest.update(value.to_be_bytes());
}
}
}
assert_eq!(
format!("{:x}", digest.finalize()),
"a9ed25caa50b9aa340faa4bb417b3d533b061f0dce66514e4bb212931444dee6"
);
}
#[test]
fn retained_geometry_matches_original_generator() {
let uv = [0.8125, 0.03125];
for seed in seeds() {
let local = geometry(seed, uv);
assert_eq!(local.len(), QUADS as usize * 4);
for (at, camera) in [
([0.0; 3], DVec3::ZERO),
([12.5, 64.0, -12.5], DVec3::new(-4.25, 71.125, 32.5)),
(
[29_999_999.5, -64.0, -29_999_999.5],
DVec3::new(29_999_987.125, 67.75, -29_999_983.875),
),
] {
let offset = offset(&Bolt { seed, at }, camera);
let translated: Vec<_> = local
.iter()
.map(|v| Vertex {
pos: (glam::Vec3::from_array(offset) + glam::Vec3::from_array(v.pos))
.to_array(),
..*v
})
.collect();
let expected = reference(seed, at, camera, uv);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&translated),
bytemuck::cast_slice::<_, u8>(&expected),
"seed {seed}"
);
}
}
}
#[test]
fn cache_tracks_seed_lifecycle_movement_and_atlas_reload() {
let mut cache = Cache::new();
let builds = Cell::new(0);
let build = |seed, uv| {
builds.set(builds.get() + 1);
geometry(seed, uv)
};
let mut bolts = [
Bolt {
seed: 123,
at: [0.0; 3],
},
Bolt {
seed: 456,
at: [1.0; 3],
},
];
fn input(bolts: &[Bolt]) -> Input<'_> {
Input {
bolts,
uv: [0.25, 0.5],
}
}
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 2);
assert_eq!(cache.meshes.len(), 2);
// Any number of rendered frames or multiplayer position updates reuse meshes.
for i in 0..100 {
bolts[0].at = [i as f64; 3];
cache.prepare(&input(&bolts), build);
}
assert_eq!(builds.get(), 2);
// A flash changes the seed; retire its previous mesh immediately.
bolts[0].seed = 789;
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 3);
assert!(!cache.meshes.contains_key(&123));
// Removal retains other bolts, and repeated seeds still share geometry.
bolts[0].seed = 456;
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 3);
assert_eq!(cache.meshes.len(), 1);
// Atlas replacement must update white texel UVs without changing the seed.
cache.prepare(
&Input {
bolts: &bolts,
uv: [0.75, 0.25],
},
build,
);
assert_eq!(builds.get(), 4);
assert_eq!(cache.meshes[&456].mesh[0].uv, [0.75, 0.25]);
// Disconnect/dimension switch/expiry drops every retained resource.
cache.prepare(&Input::default(), build);
assert!(cache.meshes.is_empty());
assert_eq!(cache.meshes.capacity(), 0);
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 5);
}
#[test]
fn long_flash_sequence_keeps_only_active_seeds() {
let mut cache = Cache::new();
for seed in 0..10_000 {
cache.prepare(
&Input {
bolts: &[Bolt { seed, at: [0.0; 3] }],
uv: [0.0; 2],
},
|seed, _| seed,
);
assert_eq!(cache.meshes.len(), 1);
assert_eq!(cache.meshes[&seed].mesh, seed);
assert!(cache.meshes.capacity() < 16);
}
}
/// Exercise the actual entity shader and retained draw path offscreen. Run
/// explicitly, separately from other headless device tests (some GPU drivers
/// fail during concurrent device teardown). Machines without an adapter skip it.
#[test]
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn retained_gpu_draw_matches_transient_additive_draw() {
use bytemuck::Zeroable;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
};
eprintln!("lightning differential GPU: {}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Lightning differential shader"),
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
// Attribute 7: 6 is the entity vertex buffer's glint UV.
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
let vertices = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})];
let retained_vertices = [
vertices[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &offsets,
}),
];
let pipelines: Vec<_> = [false, true]
.into_iter()
.map(|retained| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Lightning differential pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(if retained {
"vs_lightning"
} else {
"vs_entity"
}),
compilation_options: Default::default(),
buffers: if retained {
&retained_vertices
} else {
&vertices
},
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let globals = crate::gfx::Globals {
view_proj: (glam::Mat4::orthographic_rh(-100.0, 100.0, -10.0, 150.0, 0.1, 400.0)
* glam::Mat4::look_at_rh(
glam::Vec3::new(80.0, 30.0, 160.0),
glam::Vec3::new(0.0, 60.0, 0.0),
glam::Vec3::Y,
))
.to_cols_array_2d(),
..crate::gfx::Globals::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let size = wgpu::Extent3d {
width: 256,
height: 256,
depth_or_array_layers: 1,
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let white = device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[255; 4],
)
.create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let groups: Vec<_> = pipelines
.iter()
.map(|pipeline| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&white),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&white),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
})
})
.collect();
let mut renderer = Renderer::new();
let mut uploads = Uploads::new(&device);
let mut bolts = [Bolt {
seed: 123,
at: [29_999_999.5, 64.0, -29_999_999.5],
}; 3];
bolts[1].seed = -456;
bolts[1].at[0] -= 32.0;
bolts[2].at[2] += 20.0;
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS * 3);
for frame in 0..4 {
let camera = DVec3::new(29_999_997.125 + frame as f64, 62.75, -29_999_998.875);
if frame == 2 {
bolts[0].seed = 789;
}
let input = Input {
bolts: &bolts[..if frame == 3 { 1 } else { 3 }],
uv: if frame == 3 { [0.75; 2] } else { [0.5; 2] },
};
let expected: Vec<_> = input
.bolts
.iter()
.flat_map(|bolt| reference(bolt.seed, bolt.at, camera, input.uv))
.collect();
let transient = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&expected),
usage: wgpu::BufferUsages::VERTEX,
});
let mut encoder = device.create_command_encoder(&Default::default());
renderer.prepare(&device, &mut uploads, &mut encoder, &input, camera);
let mut readbacks = Vec::new();
for mode in 0..2 {
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = color.create_view(&Default::default());
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
)
.create_view(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&pipelines[mode]);
pass.set_bind_group(0, &groups[mode], &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if mode == 0 {
pass.set_vertex_buffer(0, transient.slice(..));
pass.draw_indexed(0..expected.len() as u32 / 4 * 6, 0, 0..1);
} else {
renderer.draw(&mut pass, &input);
}
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 256 * 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(1024),
rows_per_image: None,
},
},
size,
);
readbacks.push(read);
}
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for read in &readbacks {
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels: Vec<Vec<u8>> = readbacks
.iter()
.map(|read| read.slice(..).get_mapped_range().unwrap().to_vec())
.collect();
assert!(pixels[0].iter().any(|&b| b != 0), "empty reference frame");
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
}
}
@@ -1,328 +0,0 @@
//! The material stage of Horizon's own shader packs (docs/shaders.md): the pack's `material`
//! function gives the lit colour of each point of a block's surface, before the alpha test and
//! fog. Terrain still draws with Horizon's meshes, culling and draw lists; only its pipelines
//! change. Their shader is the pack's stage (`CheckedStage::source`), then a copy of
//! terrain.wgsl's terrain section whose names all take a `horizon_terrain_` prefix
//! (`wgsl_prefix.rs`), then entries that hand the pack what vanilla's `fs_solid` and
//! `fs_translucent` combine: the texture, the vertex tint, the light levels and the lightmap's
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
//! pack's frame at group 3.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) lightmap: vec3<f32>,
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.layer = layer;
out.tint = vec4(color.rgb, 1.0);
let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos));
let sky = f32(light.x);
// 16 per level, 15 levels.
out.light = min(vec2(block, sky) / 240.0, vec2(1.0));
out.lightmap = horizon_terrain_sample_lightmap(sky / 16.0, block / 16.0).rgb;
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
return out;
}
@vertex fn horizon_material_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
@vertex fn horizon_material_vs_flat(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonMaterialOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
}
";
/// What terrain pipelines are built against: the renderer's layouts and constants.
pub struct TerrainShape<'a> {
/// The terrain's group 0.
pub globals: &'a wgpu::BindGroupLayout,
/// The flat draws' group 1, where they run (`flat.rs`).
pub flat: Option<&'a wgpu::BindGroupLayout>,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`.
pub sprites: [f64; 2],
/// The world target's colour format.
pub format: wgpu::TextureFormat,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
/// renderer's instanced draws and, where they run, its flat ones.
pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
/// every name it declares prefixed.
fn vanilla_terrain() -> String {
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX,
)
}
/// The terrain shader for the pack's material stage `source`.
fn shader_source(source: &str) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
vanilla_terrain()
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
let text = shader_source(&stage.source);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
impl NativeMaterial {
/// The pipelines of the pack's material `stage` with the option values `constants`.
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
) -> Result<Self, String> {
let module = module(stage)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame.layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack terrain"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
// As the renderer's terrain pipelines (gfx.rs): back faces culled, reversed depth.
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment: &str,
blend: Option<wgpu::BlendState>| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let blend = Some(wgpu::BlendState::ALPHA_BLENDING);
let instanced = layout(None);
let buffers = [Some(vertices.clone()), Some(instances)];
let solid = pipeline(
&instanced,
"horizon_material_vs",
&buffers,
"horizon_material_fs_solid",
None,
);
let translucent = pipeline(
&instanced,
"horizon_material_vs",
&buffers,
"horizon_material_fs_translucent",
blend,
);
let flat = shape.flat.map(|group| {
let layout = layout(Some(group));
let buffers = [Some(vertices.clone())];
let vs = "horizon_material_vs_flat";
(
pipeline(&layout, vs, &buffers, "horizon_material_fs_solid", None),
pipeline(
&layout,
vs,
&buffers,
"horizon_material_fs_translucent",
blend,
),
)
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(Self {
solid,
translucent,
flat,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"material.wgsl\"\n\
[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\
default = 1.0\nmin = 0.0\nmax = 2.0\n";
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
assert_eq!(report.stages[0].kind, StageKind::Material);
report.stages.remove(0)
}
#[test]
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
let terrain = vanilla_terrain();
for name in [
"Globals",
"globals",
"sprites_7",
"sprite",
"SPRITE_SPLIT",
"block_slots",
"sample_lightmap",
"apply_fog",
] {
assert!(terrain.contains(&format!("{PREFIX}{name}")), "{name}");
}
assert!(!terrain.contains("fs_sky"), "the sky stays out");
// A pack may use any name Horizon's terrain uses unprefixed.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\n\
fn sprite(c: vec4<f32>) -> vec4<f32> { return c * PI; }\n\
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
return Surface(apply_fog(sprite(lit)));\n}\n";
let stage = stage(own);
let module = module(&stage).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
let entries: Vec<&str> = module
.entry_points
.iter()
.map(|e| e.name.as_str())
.collect();
for entry in [
"horizon_material_vs",
"horizon_material_vs_flat",
"horizon_material_fs_solid",
"horizon_material_fs_translucent",
] {
assert!(entries.contains(&entry), "{entry}");
}
drop(info);
assert_eq!(
present_constants(&module, &[("gain".into(), 1.5), ("other".into(), 1.0)]),
[("gain", 1.5)]
);
}
}
@@ -1,74 +0,0 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky, material and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
pub struct NativePack {
pub frame: NativeFrame,
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
/// Also returns its stages' labels, in the order they draw. Errors name the stage wgpu
/// refused.
pub fn new(
device: &wgpu::Device,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let frame = NativeFrame::new(device);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
.partition(|s| s.kind == StageKind::Post);
let sky = others
.iter()
.find(|s| s.kind == StageKind::Sky)
.map(|stage| NativeSky::new(device, globals, &frame, stage, constants, format))
.transpose()?;
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
others
.iter()
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
frame,
sky,
material,
post,
timing: None,
};
Ok((pack, labels))
}
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
}
}
@@ -1,555 +0,0 @@
//! The post stage of Horizon's own shader packs (docs/shaders.md): each `[[post]]` pass is a
//! full-screen pipeline built from its checked naga module (`horizon_shader_api::check`), with
//! the pack's options as pipeline constants. A pass reads bind group 3: the frame, the world's
//! colour and depth, the previous pass's output and two samplers. Passes before the last write
//! half-float targets of the world's size, so values over 1 carry to the next; the last draws
//! onto the screen. Every stage of a pack reads the same `frame`, written once a frame
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// One bind group per pass, for these world views.
groups: Option<(WorldViews, Vec<wgpu::BindGroup>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame.
pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the stages that read only the frame (the sky).
pub layout: wgpu::BindGroupLayout,
pub group: wgpu::BindGroup,
start: std::time::Instant,
frames: u32,
}
/// The uniform `frame` at binding 0, read by `visibility`.
fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Self {
buffer,
layout,
group,
start: std::time::Instant::now(),
frames: 0,
}
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let inputs = frame_inputs(frame, size, seconds, self.frames);
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
pub fn present_constants<'a>(
module: &naga::Module,
constants: &'a [(String, f64)],
) -> Vec<(&'a str, f64)> {
constants
.iter()
.filter(|(id, _)| {
module
.overrides
.iter()
.any(|(_, o)| o.name.as_ref() == Some(id))
})
.map(|(id, v)| (id.as_str(), *v))
.collect()
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
}
}
impl NativePost {
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let float = wgpu::TextureSampleType::Float { filterable: true };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack post"),
bind_group_layouts: &groups,
..Default::default()
});
let count = stages.len();
let mut passes = Vec::with_capacity(count);
for (i, stage) in stages.into_iter().enumerate() {
let present = present_constants(&stage.module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(stage.module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_vs_post"),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
passes.push((stage.label, pipeline));
}
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Ok(Self {
passes,
layout,
frame: frame.buffer.clone(),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
groups: None,
format,
})
}
/// The passes' labels, in order.
pub fn labels(&self) -> Vec<&str> {
self.passes.iter().map(|(l, _)| l.as_str()).collect()
}
fn prepare(
&mut self,
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
self.targets = Some((
size,
[
target("Native shader pack A"),
target("Native shader pack B"),
],
));
self.groups = None;
}
if self
.groups
.as_ref()
.is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
{
return;
}
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
_ => world.0,
};
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
],
})
})
.collect();
self.groups = Some(((world.0.clone(), world.1.clone()), groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
/// `screen`, timed by `timing` when it times this frame.
pub fn run(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
}
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
* frame.bob
* frame.spin;
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
// angle, of straight up): overhead at 0, rising in the east (+x).
let body = |angle: f32| {
let a = angle.to_radians();
Vec3::new(-a.sin(), a.cos(), 0.0)
};
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => (
[f.color[0], f.color[1], f.color[2]],
f.environmental_start.min(f.render_distance_start),
f.environmental_end.min(f.render_distance_end),
),
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let pack = &frame.pack;
let level = |l: i32| l as f32 / 15.0;
let m = |m: Mat4| m.to_cols_array_2d();
FrameInputs {
view: m(view),
projection: m(projection),
view_inverse: m(view.inverse()),
projection_inverse: m(projection.inverse()),
camera_position: frame.camera.as_vec3().to_array(),
time: seconds,
sun_direction: body(frame.sky.sun_angle).to_array(),
day_time: pack.day_time.rem_euclid(24000) as f32 / 24000.0,
moon_direction: body(frame.sky.moon_angle).to_array(),
game_time: frame.shader_game_time,
sky_color: frame.sky.sky_color,
rain: pack.rain,
fog_color,
thunder: pack.thunder,
resolution: [size[0] as f32, size[1] as f32],
eye_light: [level(pack.eye_light[0]), level(pack.eye_light[1])],
fog_start,
fog_end,
render_distance: frame.distance,
far: frame.depth_far,
night_vision: frame.night_vision,
blindness: pack.blindness,
darkness: frame.darkness.1.clamp(0.0, 1.0),
brightness: pack.brightness,
eye_in: pack.eye_in as u32,
dimension: if frame.end_sky {
1
} else if frame.nether_lighting {
-1
} else {
0
},
frame_index: index,
hide_gui: u32::from(pack.hide_gui),
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn texture(
device: &wgpu::Device,
format: wgpu::TextureFormat,
usage: wgpu::TextureUsages,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
}
/// Two passes: the first doubles the world's colour (over 1, so the half-float target
/// must keep it) times an option, the second halves it and writes the depth as alpha.
#[test]
fn passes_chain_through_half_float_targets_with_options_and_depth() {
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n\
[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\ndefault = 0.0\nmin = 0.0\nmax = 2.0\n";
let a = "override gain: f32;\nfn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 2.0 * gain, 1.0); }\n";
let b = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(input.color.rgb * 0.5, input.depth); }\n";
let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&FrameInputs::default()),
);
let mut post = NativePost::new(
&device,
report.stages,
&[("gain".into(), 1.0)],
format,
&frame,
)
.unwrap();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.8,
g: 0.4,
b: 0.2,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.5),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
let view = screen.create_view(&Default::default());
post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
assert!(
near(pixel[0], 204) && near(pixel[1], 102) && near(pixel[2], 51) && near(pixel[3], 128),
"{pixel:?}"
);
}
}
@@ -1,379 +0,0 @@
//! The sky stage of Horizon's own shader packs (docs/shaders.md): the pack's `sky` function,
//! called for each pixel of the sky disc with vanilla's colour along its view ray. The
//! pipeline's shader is the pack's stage (`CheckedStage::source`) followed by a copy of the
//! renderer's sky (terrain.wgsl's `Globals`, `linear_fog` and sky section) whose names all
//! take a `horizon_sky_` prefix, so none can meet a pack's (the check refuses `horizon_`
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_sky_";
/// The fragment entry that calls the pack: vanilla's colour in, an opaque colour out.
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
}
";
pub struct NativeSky {
pipeline: wgpu::RenderPipeline,
pub label: String,
}
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
fn vanilla_sky() -> String {
let text = [
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
]
.concat();
prefixed(&text, PREFIX)
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
)
}
/// A sky pipeline over `module` whose fragment entry is `fragment`.
fn pipeline(
device: &wgpu::Device,
layout: &wgpu::PipelineLayout,
module: &wgpu::ShaderModule,
fragment: &str,
constants: &[(&str, f64)],
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
let options = wgpu::PipelineCompilationOptions {
constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack sky"),
layout: Some(layout),
vertex: wgpu::VertexState {
module,
entry_point: Some("horizon_sky_vs_sky"),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// The sky's naga module and the layout of its pipelines: `globals` at group 0, the frame
/// at group 3.
fn build(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(globals);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack sky"),
bind_group_layouts: &groups,
..Default::default()
});
Ok((module, layout))
}
impl NativeSky {
/// The pipeline of the pack's sky `stage` with the option values `constants`, drawing
/// onto the world's target of `format`; `globals` is the terrain's group 0.
pub fn new(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
) -> Result<Self, String> {
let (module, layout) = build(device, globals, frame, stage)?;
let present = present_constants(&module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let pipeline = pipeline(
device,
&layout,
&module,
"horizon_sky_fs_pack",
&present,
format,
);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(Self {
pipeline,
label: stage.label.clone(),
})
}
/// Draws the sky in a pass whose group 0 is the terrain's.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, frame: &NativeFrame) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(GROUP, &frame.group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{Frame as FrameInputs, MANIFEST, Pack, StageKind, check};
fn stage(sky: &str) -> CheckedStage {
let mut manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"sky.wgsl\"\n"
.to_owned();
if sky.contains("gain") {
manifest += "[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\
default = 1.0\nmin = 0.0\nmax = 2.0\n";
}
let manifest = manifest.as_str();
let files = [(MANIFEST, manifest), ("sky.wgsl", sky)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
assert_eq!(report.stages[0].kind, StageKind::Sky);
report.stages.remove(0)
}
#[test]
fn the_renderer_s_sky_takes_a_prefix_and_validates_with_a_pack() {
let sky = vanilla_sky();
for name in [
"Globals",
"SkyOut",
"view_ray",
"PI",
"sky_along_ray",
"linear_fog",
"fs_sky",
] {
assert!(sky.contains(&format!("{PREFIX}{name}")), "{name}");
}
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::default(),
)
.validate(&module)
.unwrap();
}
/// Vanilla's sky and two packs over the same globals: one that returns its input draws
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
#[test]
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let mut globals: super::super::Globals = bytemuck::Zeroable::zeroed();
globals.inverse_view = glam::Mat4::IDENTITY.to_cols_array_2d();
globals.misc = [0.0, 0.0, 1.0, 1.0];
globals.fog_color = [0.2, 0.4, 0.6, 1.0];
globals.sky_color = [0.5, 0.7, 1.0, 1.0];
globals.fog_ends = [64.0, 0.0, 0.0, 0.0];
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: size_of::<super::super::Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&globals_buffer, 0, bytemuck::bytes_of(&globals));
let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &globals_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&FrameInputs::default()),
);
let gain = [("gain".to_owned(), 1.0)];
let draw = |stage: &CheckedStage, vanilla: bool| -> Vec<u8> {
let sky =
NativeSky::new(&device, &globals_layout, &frame, stage, &gain, format).unwrap();
let pipeline = if vanilla {
let (module, layout) = build(&device, &globals_layout, &frame, stage).unwrap();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
pipeline(&device, &layout, &module, "horizon_sky_fs_sky", &[], format)
} else {
sky.pipeline.clone()
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let (color_view, depth_view) = (
color.create_view(&Default::default()),
depth.create_view(&Default::default()),
);
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(0, &globals_group, &[]);
pass.set_pipeline(&pipeline);
pass.set_bind_group(GROUP, &frame.group, &[]);
pass.draw(0..3, 0..1);
}
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
(0..4)
.flat_map(|row| {
let start = row * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
bytes[start..start + 16].to_vec()
})
.collect()
};
let same = stage("fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n");
let vanilla = draw(&same, true);
assert_eq!(draw(&same, false), vanilla);
// The top row looks up into the sky disc, the bottom one down into the fog.
assert_ne!(vanilla[..4], vanilla[vanilla.len() - 4..]);
let inverse = stage(
"override gain: f32;\nfn sky(input: SkyInput) -> vec4<f32> { return vec4(vec3(gain) - input.color.rgb, 0.25); }\n",
);
let inverted = draw(&inverse, false);
for (got, want) in inverted.chunks(4).zip(vanilla.chunks(4)) {
assert_eq!(got[3], 255);
for i in 0..3 {
assert!(got[i].abs_diff(255 - want[i]) <= 1, "{got:?} {want:?}");
}
}
}
}
@@ -1,299 +0,0 @@
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
//! layer is copied into a grid, row by row from the top left, and an animated layer again
//! whenever its frame changes.
pub(super) struct PackAtlas {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// Sprites per row.
pub columns: u32,
/// A sprite's edge in pixels, at the first level.
pub sprite: u32,
/// Layers per sprite array.
split: u32,
}
impl PackAtlas {
/// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels`
/// mip levels); `None` when the grid is larger than the device allows.
pub fn new(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
let rows = layers.div_ceil(columns);
let max = device.limits().max_texture_dimension_2d;
if columns * sprite > max || rows * sprite > max {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
width: columns * sprite,
height: rows * sprite,
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let atlas = Self {
texture,
view,
columns,
sprite,
split,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
}
/// The atlas's size in pixels (`atlasSize`).
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
}
/// Copies `layers` from the sprite arrays again, every level.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
layers: impl IntoIterator<Item = u32>,
) {
for layer in layers {
let Some(array) = arrays.get((layer / self.split) as usize) else {
continue;
};
let (column, row) = (layer % self.columns, layer / self.columns);
for level in 0..self.texture.mip_level_count() {
let edge = self.sprite >> level;
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: array,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer % self.split,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: level,
origin: wgpu::Origin3d {
x: column * edge,
y: row * edge,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: edge,
height: edge,
depth_or_array_layers: 1,
},
);
}
}
}
}
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
vec3 hz_e1 = hz_p1 - hz_p0;\n \
vec3 hz_e2 = hz_p2 - hz_p0;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
if (dot(hz_n, hz_n) > 1e-12) {\n \
hz_Normal = normalize(hz_n);\n \
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
if (dot(hz_t, hz_t) > 1e-12) {\n \
hz_t = normalize(hz_t - hz_Normal * dot(hz_Normal, hz_t));\n \
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
}\n \
}\n";
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
// and the arena page's vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
};
let [width, height] = self.size();
let scale = [
self.sprite as f32 / width as f32,
self.sprite as f32 / height as f32,
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
);
let mut body = body;
if flat {
body.push_str(FACE);
}
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
let entity = if flat {
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
let attributes = [
("mc_Entity", entity),
(
"mc_midTexCoord",
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
),
("at_tangent", "hz_tangent"),
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_MultiTexCoord0.xy"),
("vaUV2", "hz_MultiTexCoord1.xy"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
alpha_test: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
#[test]
fn layers_land_in_their_cells() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let (split, layers, sprite) = (3u32, 5u32, 2u32);
let arrays: Vec<wgpu::Texture> = (0..2)
.map(|a| {
let count = (layers - a * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4))
.collect();
wgpu::util::DeviceExt::create_texture_with_data(
&device,
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: sprite,
height: sprite,
depth_or_array_layers: count,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap();
assert_eq!((atlas.columns, atlas.size()), (3, [6, 4]));
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
atlas.texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 6,
height: 4,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
let red = |x: usize, y: usize| data[y * 256 + x * 4];
// Layer 4 (second array, its second layer) is in row 1, column 1.
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
}
@@ -1,152 +0,0 @@
//! The uniforms a shader pack's gbuffers programs read per draw (`HzDraw` at set 2, the
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
//! dynamic offset at each draw.
use horizon_shaders::{RENDER_STAGES, render_stage};
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
/// none; Horizon itself still draws the whole list at once.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EntitySpan {
/// The first vertex.
pub start: u32,
/// The render stage (5.4: `ENTITIES`, `BLOCK_ENTITIES`, `PARTICLES`, `DESTROY`), which picks
/// the program.
pub stage: u32,
/// `entityId` for entities, `blockEntityId` for block entities (5.3), else 0.
pub id: i32,
/// An entity part that blends (a name tag, a shadow): `gbuffers_entities_translucent`.
pub translucent: bool,
/// Hidden from the camera (Horizon's entity culling, the player in first person): only the
/// shadow pass draws it.
pub hidden: bool,
/// The local player (`shadowPlayer`).
pub player: bool,
/// A block entity whose block emits light (`shadowLightBlockEntities`).
pub emissive: bool,
}
impl EntitySpan {
/// Marks vertices `start..end` of `spans`' list (one entity) as hidden: the span covering
/// `start` is split there, the spans inside are hidden, and the geometry after `end` goes
/// on in the last span's kind. False when no span covers `start`.
pub fn hide(spans: &mut Vec<EntitySpan>, start: usize, end: usize) -> bool {
let Some(i) = spans.iter().rposition(|s| s.start as usize <= start) else {
return false;
};
let after = spans[spans.len() - 1];
if spans[i].start as usize != start {
let split = EntitySpan {
start: start as u32,
..spans[i]
};
spans.insert(i + 1, split);
}
for s in spans.iter_mut().skip(i) {
if s.start as usize >= start {
s.hidden = true;
}
}
if end > start {
spans.push(EntitySpan {
start: end as u32,
..after
});
}
true
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
stride: u32,
}
impl DrawUniforms {
pub fn new(device: &wgpu::Device) -> Self {
let stride = SIZE.max(device.limits().min_uniform_buffer_offset_alignment);
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack draws"),
contents: &contents,
usage: wgpu::BufferUsages::UNIFORM,
},
);
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack draws"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(u64::from(SIZE)),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack draws"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: wgpu::BufferSize::new(u64::from(SIZE)),
}),
}],
});
Self {
layout,
group,
stride,
}
}
/// Draws that follow read `renderStage` = `MC_RENDER_STAGE_<stage>`, other values zero.
pub fn bind(&self, pass: &mut wgpu::RenderPass<'_>, stage: &str) {
pass.set_bind_group(2, &self.group, &[self.stride * render_stage(stage)]);
}
}
#[cfg(test)]
mod tests {
use super::EntitySpan;
fn span(start: u32, id: i32) -> EntitySpan {
EntitySpan {
start,
id,
..Default::default()
}
}
#[test]
fn hide_splits_the_span_around_one_entity() {
// Two cows in one span: the second (vertices 8..16) is hidden.
let mut spans = vec![span(0, 7)];
assert!(EntitySpan::hide(&mut spans, 8, 16));
let hidden = EntitySpan {
hidden: true,
..span(8, 7)
};
assert_eq!(spans, [span(0, 7), hidden, span(16, 7)]);
// An entity with its own span and a part after it (armour) is hidden whole.
let mut spans = vec![span(0, 1), span(4, 2), span(6, 3)];
assert!(EntitySpan::hide(&mut spans, 4, 10));
assert!(!spans[0].hidden && spans[1].hidden && spans[2].hidden);
assert_eq!(spans[3], span(10, 3));
assert!(!EntitySpan::hide(&mut Vec::new(), 0, 4));
}
}
@@ -1,524 +0,0 @@
//! Entities, block entities, translucent entity parts and particles drawn by a shader pack's
//! `gbuffers_entities`, `gbuffers_block`, `gbuffers_entities_translucent` and
//! `gbuffers_particles` (spec 2.3, 5.3, 5.4): the runs of `Frame::entities` the game marked (`EntitySpan`), each its
//! own draw with its `entityId` or `blockEntityId` and `entityColor` in a per-draw slot of
//! `HzDraw` (set 2). Horizon draws whatever the pack does not.
use std::ops::Range;
use horizon_shaders::render_stage;
use super::Vertex;
use super::pack_draw::EntitySpan;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
const ATTRIBUTES: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4, 4 => Uint8x4, 5 => Snorm8x4
];
/// The entity vertex: Horizon's own (`Vertex`).
pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRIBUTES,
})]
}
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
vec4 hz_tangent; vec2 hz_mid;\n"
.to_owned();
let words = std::mem::size_of::<Vertex>() / 4;
let corner = |k: usize, field: &str| {
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
match field {
"pos" => format!("uintBitsToFloat(uvec3({}, {}, {}))", at(0), at(1), at(2)),
_ => format!("uintBitsToFloat(uvec2({}, {}))", at(3), at(4)),
}
};
let body = format!(
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
vec3 hz_e1 = {p1} - {p0};\n \
vec3 hz_e2 = {p2} - {p0};\n \
vec2 hz_t0 = {t0};\n \
vec2 hz_d1 = {t1} - hz_t0;\n \
vec2 hz_d2 = {t2} - hz_t0;\n \
hz_mid = (hz_t0 + {t2}) * 0.5;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz)\n \
: dot(hz_n, hz_n) > 1e-12 ? normalize(hz_n) : vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n \
float hz_r = hz_d1.x * hz_d2.y - hz_d2.x * hz_d1.y;\n \
vec3 hz_t = (hz_e1 * hz_d2.y - hz_e2 * hz_d1.y) * sign(hz_r);\n \
vec3 hz_b = (hz_e2 * hz_d1.x - hz_e1 * hz_d2.x) * sign(hz_r);\n \
hz_t -= hz_Normal * dot(hz_Normal, hz_t);\n \
if (dot(hz_t, hz_t) > 1e-12) {{\n \
hz_t = normalize(hz_t);\n \
hz_tangent = vec4(hz_t, dot(cross(hz_t, hz_Normal), hz_b) < 0.0 ? -1.0 : 1.0);\n \
}}\n \
hz_Vertex = vec4(hz_v_pos, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n",
p0 = corner(0, "pos"),
p1 = corner(1, "pos"),
p2 = corner(2, "pos"),
t0 = corner(0, "uv"),
t1 = corner(1, "uv"),
t2 = corner(2, "uv"),
);
let attributes = [
("at_tangent", "hz_tangent"),
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV1", "ivec2(0, 10)"),
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
alpha_test: Some(horizon_shaders::AlphaTest {
op: ">",
reference: 0.1,
}),
}
}
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full sky light. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) uniform HzCloud { vec4 hz_cloud_offset; vec4 hz_cloud_colors[4]; };\n"
.to_owned();
let body = " hz_Vertex = vec4(hz_v_pos + hz_cloud_offset.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_cloud_colors[min(hz_v_light.x, 3u)];\n \
hz_Normal = dot(hz_v_normal.xyz, hz_v_normal.xyz) > 0.01 ? normalize(hz_v_normal.xyz) : vec3(0.0, 1.0, 0.0);\n"
.to_owned();
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaUV0", "hz_v_uv"),
("vaUV2", "ivec2(0, 240)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after: String::new(),
alpha_test: None,
}
}
/// `entityColor` for a run whose first quad carries overlay code `overlay`: the hurt red or a
/// white flash, its alpha how much of that colour shows over the entity (vanilla mixes the
/// overlay texel's colour in by one minus the texel's alpha).
pub fn entity_color(overlay: u32) -> [f32; 4] {
let [r, g, b, a] = crate::overlay::HURT_TEXEL.map(|v| f32::from(v) / 255.0);
let white = |u: u32| {
let a = f32::from(crate::overlay::white_alpha(u)) / 255.0;
[1.0, 1.0, 1.0, 1.0 - a]
};
match overlay {
1 | 8 => [r, g, b, 1.0 - a],
5 => white(15),
16..32 => white(overlay - crate::overlay::WHITE),
_ => [0.0; 4],
}
}
/// One draw: its quads in `PackEntities`' buffer, its program and its slot of `HzDraw`.
pub(super) struct Draw {
/// The first vertex.
pub base: u32,
pub quads: u32,
/// The kind of program that draws it (`ENTITY`, `BLOCK`, `TRANSLUCENT`, `PARTICLE`).
pub program: usize,
/// The pack's gbuffers program draws it (else Horizon does).
pub pack: bool,
/// The pack's shadow program draws it into the shadow map too.
pub shadow: bool,
pub slot: u32,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Casters {
/// `shadow_entities` (or `shadow`) draws entities...
pub entities: bool,
/// ...only the local player (`shadowPlayer` without `shadowEntities`).
pub only_player: bool,
/// `shadow_block` (or `shadow`) draws block entities...
pub blocks: bool,
/// ...only those whose block emits light (`shadowLightBlockEntities`).
pub only_lit: bool,
}
impl Casters {
/// Whether the shadow pass draws `span`'s run, of kind `program`.
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
match program {
ENTITY => self.entities && (!self.only_player || span.player),
BLOCK => self.blocks && (!self.only_lit || span.emissive),
_ => false,
}
}
}
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
pub(super) struct PackEntities {
vertices: wgpu::Buffer,
/// Set 1: the vertices again, for the quads' corners.
pub table: wgpu::BindGroupLayout,
pub table_group: wgpu::BindGroup,
slots: wgpu::Buffer,
pub group: wgpu::BindGroup,
stride: u64,
/// Two triangles per quad, for `quads` quads.
pub indices: wgpu::Buffer,
quads: u32,
scratch: Vec<Vertex>,
uniforms: Vec<u8>,
pub draws: Vec<Draw>,
}
impl PackEntities {
pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self {
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack entity vertices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
Self {
vertices,
table,
table_group,
slots,
group,
stride,
indices: quad_indices(device, 1024),
quads: 1024,
scratch: Vec::new(),
uniforms: Vec::new(),
draws: Vec::new(),
}
}
fn vertex_buffer(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity vertices"),
size: vertices * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity vertices"),
layout: table,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, group)
}
fn slot_buffer(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
slots: u64,
stride: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity draws"),
size: slots * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity draws"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
(buffer, group)
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
/// The dynamic offset of draw slot `slot`.
pub fn offset(&self, slot: u32) -> u32 {
(u64::from(slot) * self.stride) as u32
}
/// Splits `entities` between the pack, which draws the runs of `spans` its programs cover
/// (`drawn`: whether it has `gbuffers_entities`, `gbuffers_block` or their fallbacks), and
/// Horizon, which draws the rest (`rest`). Uploads the pack's part, and the runs its shadow
/// programs draw (`shadow`). Runs hidden from the camera only ever cast shadows.
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|_| plain(run));
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
rest.extend_from_slice(run);
}
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
continue;
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: run.len() as u32 / 4,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
}
if self.draws.is_empty() {
return;
}
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
(self.vertices, self.table_group) =
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
}
if need_slots > have_slots {
(self.slots, self.group) =
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
}
let quads = self.draws.iter().map(|d| d.quads).max().unwrap_or(0);
if quads > self.quads {
self.quads = quads.next_power_of_two();
self.indices = quad_indices(device, self.quads);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
/// Two triangles for each of `quads` quads, from vertex 0.
pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
.collect();
wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack entity indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
},
)
}
/// Whether a run holds only geometry a gbuffers program can draw from the shared atlas: no
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// The programs of entity runs, in the order they draw.
pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const PROGRAMS: usize = 4;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
#[cfg(test)]
fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
program_of(span).filter(|&p| drawn[p])
}
/// The kind of program that draws `span`'s run, when a pack may.
pub fn program_of(span: &EntitySpan) -> Option<usize> {
Some(if span.stage == render_stage("ENTITIES") {
if span.translucent {
TRANSLUCENT
} else {
ENTITY
}
} else if span.stage == render_stage("BLOCK_ENTITIES") {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else {
return None;
})
}
/// Each span with its vertex range: from its start to the next span's (or the end).
pub fn runs(
len: usize,
spans: &[EntitySpan],
) -> impl Iterator<Item = (EntitySpan, Range<usize>)> + '_ {
spans.iter().enumerate().map(move |(i, s)| {
let end = spans.get(i + 1).map_or(len, |n| n.start as usize);
let start = (s.start as usize).min(len);
(*s, start..end.clamp(start, len))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
};
let span = |start, stage: &str, id| EntitySpan {
start,
stage: render_stage(stage),
id,
..Default::default()
};
let spans = [
span(2, "ENTITIES", 7),
span(5, "PARTICLES", 0),
span(6, "BLOCK_ENTITIES", 9),
];
let ranges: Vec<_> = runs(10, &spans).map(|(_, r)| r).collect();
assert_eq!(ranges, [2..5, 5..6, 6..10]);
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
assert_eq!(entity_color(1)[0], 1.0);
assert!((entity_color(1)[3] - (1.0 - 178.0 / 255.0)).abs() < 1e-6);
assert_eq!(entity_color(0), [0.0; 4]);
// The shadow pass: entities, the player alone, block entities, those that emit light.
let all = Casters {
entities: true,
blocks: true,
..Default::default()
};
let player = EntitySpan {
player: true,
..spans[0]
};
let lit = EntitySpan {
emissive: true,
..spans[2]
};
assert!(all.cast(&spans[0], ENTITY) && all.cast(&spans[2], BLOCK));
assert!(!all.cast(&spans[1], PARTICLE));
let some = Casters {
only_player: true,
only_lit: true,
..all
};
assert!(!some.cast(&spans[0], ENTITY) && some.cast(&player, ENTITY));
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
@@ -1,169 +0,0 @@
//! The first-person hand and held items drawn by a shader pack's `gbuffers_hand` (spec 2.3):
//! `Frame::hand`'s entity vertices, read as entities are (`pack_entities::inputs`), with the
//! hand's own projection as `gl_ProjectionMatrix` (5.2: `gbufferProjection` stays the world's).
use glam::Mat4;
use super::Vertex;
/// `MC_HAND_DEPTH`'s value (1.5): the hand's depth is scaled by it through its projection, so
/// that it sits in front of the world in the shared depth buffer.
const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
inputs.body.push_str(
" hz_ProjectionMatrix = hz_hand_projection;\n \
hz_ProjectionMatrixInverse = hz_hand_projection_inverse;\n \
hz_ModelViewProjectionMatrix = hz_hand_projection * hz_ModelViewMatrix;\n",
);
inputs
}
/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
/// `MC_HAND_DEPTH`.
pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
* bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
/// indices.
pub struct PackHand {
pub layout: wgpu::BindGroupLayout,
vertices: wgpu::Buffer,
uniform: wgpu::Buffer,
pub group: wgpu::BindGroup,
pub indices: wgpu::Buffer,
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack hand"),
entries: &[
entry(0, wgpu::BufferBindingType::Storage { read_only: true }),
entry(1, wgpu::BufferBindingType::Uniform),
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand"),
size: UNIFORM,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
vertices,
uniform,
group,
capacity,
quads: 0,
}
}
fn vertex_buffer(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
uniform: &wgpu::Buffer,
quads: u32,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand vertices"),
size: u64::from(quads) * 4 * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: uniform.as_entire_binding(),
},
],
});
(buffer, group)
}
/// Uploads this frame's hand and its projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
if self.quads == 0 {
return;
}
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
self.indices = super::pack_entities::quad_indices(device, self.capacity);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
let (a, b) = (p * v, plain * v);
let window = (a.z / a.w) * 0.5 + 0.5;
assert!(
(window - 0.5).abs() <= HAND_DEPTH * 0.5 + 1e-6,
"{z}: {window}"
);
assert!((a.x / a.w - b.x / b.w).abs() < 1e-6);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,63 +0,0 @@
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
// depthtex0 (a depth attachment, for terrain's depth test), and colortex0 onto the screen for
// packs without `final`.
@vertex
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
struct World {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
// reverse-Z depth is 1 - d for the same near and far planes.
@fragment
fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let size = vec2<i32>(textureDimensions(source));
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0);
return out;
}
// The same without depthtex0, for packs that never read it.
@fragment
fn fs_blit_color(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let size = vec2<i32>(textureDimensions(source));
return textureLoad(source, vec2<i32>(i32(p.x), size.y - 1 - i32(p.y)), 0);
}
@group(0) @binding(2) var linear: sampler;
struct Full {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
// Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last.
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
}
@fragment
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
}
// One level of a buffer's mip chain from the level above (6.1.7): the average of the 2×2
// texels under each destination texel, by one bilinear sample at their shared corner.
@fragment
fn fs_down(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let size = max(textureDimensions(source) / 2u, vec2<u32>(1u));
return textureSampleLevel(source, linear, p.xy / vec2<f32>(size), 0.0);
}
@@ -1,208 +0,0 @@
//! A shader pack's shadow buffers (spec 6.3): the shadow map's depth (`shadowtex0`, and
//! `shadowtex1` without translucent geometry), and the colour buffers `shadowcolor0/1`, each
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
/// Loaded and stored.
pub(super) fn load<T>() -> Ops<T> {
wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}
}
fn clear(c: [f32; 4]) -> Ops<Color> {
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: c[0].into(),
g: c[1].into(),
b: c[2].into(),
a: c[3].into(),
}),
store: wgpu::StoreOp::Store,
}
}
/// What the shadow pass writes and the programs read.
pub(super) struct ShadowTargets {
pub size: u32,
/// `shadowtex0`, written by the shadow pass.
pub depth: (wgpu::Texture, wgpu::TextureView),
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides of each shadowcolor buffer used.
pub color: [Option<[wgpu::TextureView; 2]>; 2],
/// The clear colour of each buffer cleared every frame.
pub clears: [Option<[f32; 4]>; 2],
/// Bit n set: B is shadowcolor n's "main".
pub main: u16,
/// Nothing cleared yet this frame: the first pass into the shadow buffers clears them as it
/// begins (`clear_ops`), which saves a pass that loads and stores them all on tiled GPUs.
fresh: bool,
/// Bit n set: shadowcolor n was cleared that way.
cleared: u8,
/// Depth comparison for `sampler2DShadow` (hardware filtering): linear, as GL filters it.
pub comparison: wgpu::Sampler,
}
fn target(
device: &wgpu::Device,
label: &str,
size: u32,
format: wgpu::TextureFormat,
copy: wgpu::TextureUsages,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | copy,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
(texture, view)
}
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; 2],
colors: u16,
depth1: bool,
) -> Self {
let size = shadow
.resolution
.clamp(1, device.limits().max_texture_dimension_2d);
let copy = wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
let depth = wgpu::TextureFormat::Depth32Float;
let color = std::array::from_fn(|n| {
(colors & 1 << n != 0).then(|| {
[0, 1].map(|side| {
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
target(
device,
&label,
size,
formats[n],
wgpu::TextureUsages::empty(),
)
.1
})
})
});
Self {
size,
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
color,
clears: std::array::from_fn(|n| {
let b = &shadow.colors[n];
b.clear.then(|| b.clear_color.unwrap_or([1.0; 4]))
}),
main: 0,
fresh: false,
cleared: 0,
comparison: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}),
}
}
/// Side `alt` (of "main" and "alt") of shadowcolor `n`.
pub fn color(&self, n: usize, alt: bool) -> Option<&wgpu::TextureView> {
let side = usize::from(self.main & 1 << n != 0) ^ usize::from(alt);
self.color.get(n)?.as_ref().map(|c| &c[side])
}
/// A new frame: the shadow map and the shadowcolor buffers cleared every frame (6.3) are
/// cleared by the first pass that begins (`clear_ops`), or else by `clear_rest`.
pub fn begin_frame(&mut self) {
self.fresh = true;
self.cleared = 0;
}
/// The load operations of a pass into the shadow map and shadowcolor buffers `writes`:
/// clears in the frame's first one, loads after.
pub fn clear_ops(
&mut self,
writes: impl Iterator<Item = usize>,
) -> (Ops<f32>, [Ops<Color>; 2]) {
if !std::mem::take(&mut self.fresh) {
return (load(), [load(); 2]);
}
let mut colors = [load(); 2];
for n in writes.filter(|&n| n < 2) {
if let Some(c) = self.clears[n] {
colors[n] = clear(c);
self.cleared |= 1 << n;
}
}
let depth = wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
};
(depth, colors)
}
/// Clears what no pass has cleared this frame: all of it when no shadow program drew, the
/// cleared shadowcolor buffers the first one did not write.
pub fn clear_rest(&mut self, encoder: &mut wgpu::CommandEncoder) {
let depth = std::mem::take(&mut self.fresh);
let cleared = std::mem::replace(&mut self.cleared, 0b11);
let attachments: Vec<_> = (0..2)
.filter(|n| cleared & 1 << n == 0)
.filter_map(|n| {
Some(Some(wgpu::RenderPassColorAttachment {
view: self.color(n, false)?,
depth_slice: None,
resolve_target: None,
ops: clear(self.clears[n]?),
}))
})
.collect();
if !depth && attachments.is_empty() {
return;
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack shadow clear"),
color_attachments: &attachments,
depth_stencil_attachment: depth.then_some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth.1,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
/// `shadowtex1`: the shadow map as it is now (before translucent geometry).
pub fn copy_depth1(&self, encoder: &mut wgpu::CommandEncoder) {
if let Some((copy, _)) = &self.depth1 {
encoder.copy_texture_to_texture(
self.depth.0.as_image_copy(),
copy.as_image_copy(),
self.depth.0.size(),
);
}
}
}
@@ -1,323 +0,0 @@
//! The vanilla sky's geometry for a shader pack's `gbuffers_skybasic` (spec 2.3): the sky disc,
//! the sunset fan, the stars and the void disc, as Horizon's own sky draws them (terrain.wgsl,
//! stars.rs), built once into a vertex buffer. The frame's colours and rotations sit in a small
//! uniform (set 1) that the vertex inputs read. `gbuffers_skytextured` draws the frame's sun and
//! moon quads, textured from the shared atlas.
use std::ops::Range;
use wgpu::util::DeviceExt;
/// A sky vertex: a position and what it belongs to (`SKY`...).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
pos: [f32; 3],
kind: u32,
}
const SKY: u32 = 0;
const SUNSET_APEX: u32 = 1;
/// Rim of the sunset fan: `pos.xy` is (sin, cos) of its angle.
const SUNSET_RIM: u32 = 2;
const STAR: u32 = 3;
const VOID: u32 = 4;
/// The frame's sky (std140).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
/// Camera-relative world from star space.
stars: [[f32; 4]; 4],
/// Sky colour, alpha 1.
color: [f32; 4],
/// Sunset colour and alpha.
sunrise: [f32; 4],
/// cos and sin of the sunset fan's turn, star brightness.
sun: [f32; 4],
}
/// A sun or moon vertex: position, atlas UV, RGBA8 colour.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Celestial {
pos: [f32; 3],
uv: [f32; 2],
color: u32,
}
/// Quads of the sun and moon (and room to spare).
const CELESTIAL_QUADS: usize = 4;
const CELESTIAL_ATTRIBUTES: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Unorm8x4];
pub(super) const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
/// What `gbuffers_skybasic` reads from these vertices.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uint hz_v_kind;\n\
layout(set = 1, binding = 0) uniform HzSky { mat4 hz_sky_stars; vec4 hz_sky_color; \
vec4 hz_sky_sunrise; vec4 hz_sky_sun; };\n"
.to_owned();
let body = format!(
" vec3 hz_p = hz_v_pos;\n \
hz_Color = hz_sky_color;\n \
if (hz_v_kind == {SUNSET_APEX}u || hz_v_kind == {SUNSET_RIM}u) {{\n \
vec3 hz_l = vec3(0.0, 100.0, 0.0);\n \
hz_Color = hz_sky_sunrise;\n \
if (hz_v_kind == {SUNSET_RIM}u) {{\n \
hz_l = vec3(hz_v_pos.xy * 120.0, -hz_v_pos.y * 40.0 * hz_sky_sunrise.a);\n \
hz_Color.a = 0.0;\n \
}}\n \
vec3 hz_r = vec3(hz_l.x * hz_sky_sun.x - hz_l.y * hz_sky_sun.y, hz_l.x * hz_sky_sun.y + hz_l.y * hz_sky_sun.x, hz_l.z);\n \
hz_p = vec3(hz_r.x, -hz_r.z, hz_r.y);\n \
}} else if (hz_v_kind == {STAR}u) {{\n \
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
hz_Color = vec4(hz_sky_sun.z);\n \
}} else if (hz_v_kind == {VOID}u) {{\n \
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
}}\n \
hz_Vertex = vec4(hz_p, 1.0);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n"
);
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: vec![
("vaPosition".into(), "hz_Vertex.xyz".into()),
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
}
}
/// What `gbuffers_skytextured` reads from the sun and moon's vertices.
pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
horizon_shaders::GbuffersInputs {
declarations:
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in vec4 hz_v_color;\n"
.into(),
body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n \
hz_MultiTexCoord0 = vec4(hz_v_uv, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(0.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
.into(),
attributes: vec![
("vaPosition".into(), "hz_Vertex.xyz".into()),
("vaUV0".into(), "hz_v_uv".into()),
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
}
}
/// One draw of the sky: its render stage, whether `gbuffers_skytextured` draws it, its vertices.
pub(super) struct Part {
pub stage: &'static str,
pub textured: bool,
pub range: Range<u32>,
}
/// A disc fan at height `y`, radius 512, as triangles (SkyRenderer's sky and void discs).
fn disc(y: f32, kind: u32, out: &mut Vec<Vertex>) {
let sign = y.signum();
let rim = |i: i32| {
let a = (i as f32).to_radians();
Vertex {
pos: [sign * 512.0 * a.cos(), y, 512.0 * a.sin()],
kind,
}
};
let centre = Vertex {
pos: [0.0, y, 0.0],
kind,
};
for i in (-180..180).step_by(45) {
out.extend([centre, rim(i), rim(i + 45)]);
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
ranges: [Range<u32>; 4],
}
impl PackSky {
pub fn new(device: &wgpu::Device) -> Self {
let mut v = Vec::new();
disc(16.0, SKY, &mut v);
let sky = 0..v.len() as u32;
let apex = Vertex {
pos: [0.0; 3],
kind: SUNSET_APEX,
};
let rim = |i: u32| {
let a = i as f32 * std::f32::consts::TAU / 16.0;
Vertex {
pos: [a.sin(), a.cos(), 0.0],
kind: SUNSET_RIM,
}
};
for i in 0..16 {
v.extend([apex, rim(i), rim(i + 1)]);
}
let sunset = sky.end..v.len() as u32;
v.extend(
super::stars::star_triangles()
.into_iter()
.map(|pos| Vertex { pos, kind: STAR }),
);
let stars = sunset.end..v.len() as u32;
disc(-4.0, VOID, &mut v);
let void = stars.end..v.len() as u32;
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack sky"),
contents: bytemuck::cast_slice(&v),
usage: wgpu::BufferUsages::VERTEX,
});
let celestial = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack sun and moon"),
size: (std::mem::size_of::<Celestial>() * 6 * CELESTIAL_QUADS) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack sky"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack sky"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack sky"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
Self {
vertices,
celestial,
uniform,
layout,
group,
ranges: [sky, sunset, stars, void],
}
}
pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRIBUTES,
})]
}
pub fn celestial_buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Celestial>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &CELESTIAL_ATTRIBUTES,
})]
}
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
/// and stars turn with it (7.1).
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
let turn = super::pack_uniforms::tilt_matrix(tilt);
let sun_angle = frame.sky.sun_angle.to_radians();
// The fan turns to the other horizon in the afternoon (terrain.wgsl's sunrise_weight).
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
let theta = (flip + 90.0).to_radians();
let (rotation, brightness) = frame.stars.unwrap_or_default();
let [r, g, b] = frame.sky.sky_color;
let uniform = Uniform {
stars: (turn * rotation).to_cols_array_2d(),
color: [r, g, b, 1.0],
sunrise: frame.sky.sunrise_color,
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
let quads = frame
.celestial
.as_chunks::<4>()
.0
.iter()
.take(CELESTIAL_QUADS);
let triangles: Vec<Celestial> = quads
.clone()
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
.map(|v| Celestial {
pos: turn.transform_vector3(v.pos.into()).into(),
uv: v.uv,
color: v.color,
})
.collect();
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void] = self.ranges.clone();
let part = |stage, textured, range| Part {
stage,
textured,
range,
};
let mut parts = vec![part("SKY", false, sky)];
if frame.sky.sunrise_color[3] > 0.001 {
parts.push(part("SUNSET", false, sunset));
}
for (i, stage) in ["SUN", "MOON"].into_iter().enumerate().take(quads.len()) {
parts.push(part(stage, true, i as u32 * 6..i as u32 * 6 + 6));
}
if frame.stars.is_some() {
parts.push(part("STARS", false, stars));
}
if frame.dark_disc {
parts.push(part("VOID", false, void));
}
parts
}
/// Binds the vertices of the `textured` (sun and moon) or other parts.
pub fn bind(&self, pass: &mut wgpu::RenderPass<'_>, textured: bool) {
if textured {
pass.set_vertex_buffer(0, self.celestial.slice(..));
} else {
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
}
}
}
@@ -1,307 +0,0 @@
//! GPU time of each pass of a shader pack, of either kind: shown in the F3 overlay while it is
//! open, and logged every `REPORT` timed frames with `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
const CAPACITY: u32 = 64;
/// Timed frames each pass's median is taken over.
const WINDOW: usize = 15;
enum Stage {
/// The next frame is timed.
Idle,
/// The frame being recorded is timed.
Recording,
/// Submitted; the flag is set once the GPU has finished it.
Running(Arc<AtomicBool>),
/// Resolved and copied; the flag is set once the copy is mapped.
Reading(Arc<AtomicBool>, u64),
}
pub struct PassTiming {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
read: wgpu::Buffer,
period_ns: f32,
stage: Stage,
/// The passes of the timed frame, in order.
names: Vec<String>,
/// Each pass's last `WINDOW` times, in milliseconds.
samples: Vec<(String, VecDeque<f32>)>,
/// Log the medians every `REPORT` timed frames.
log: bool,
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
}
impl PassTiming {
/// `None` when the device can't time passes.
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Option<Self> {
if !device.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
return None;
}
let size = u64::from(CAPACITY) * 2 * 8;
let buffer = |label, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage,
mapped_at_creation: false,
})
};
Some(Self {
queries: device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("Shader pack pass timing"),
ty: wgpu::QueryType::Timestamp,
count: CAPACITY * 2,
}),
resolve: buffer(
"Shader pack timing resolve",
wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
),
read: buffer(
"Shader pack timing read",
wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
),
period_ns: queue.get_timestamp_period(),
stage: Stage::Idle,
names: Vec::new(),
samples: Vec::new(),
log: Self::logging(),
marker: None,
reports: 0,
})
}
/// Whether `HORIZON_SHADER_PACK_TIMING` asks for timing without the F3 overlay.
pub fn logging() -> bool {
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*LOG.get_or_init(|| std::env::var_os("HORIZON_SHADER_PACK_TIMING").is_some())
}
/// At the start of a frame's passes: moves the last timed frame along, and decides whether
/// this one is timed.
pub fn begin(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
match &self.stage {
// A timed frame that was never submitted: time this one instead.
Stage::Recording => self.stage = Stage::Idle,
Stage::Running(done) if done.load(Ordering::Acquire) => {
let bytes = self.names.len() as u64 * 16;
let mut encoder = device.create_command_encoder(&Default::default());
encoder.resolve_query_set(
&self.queries,
0..self.names.len() as u32 * 2,
&self.resolve,
0,
);
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.read, 0, bytes);
queue.submit([encoder.finish()]);
let mapped = Arc::new(AtomicBool::new(false));
let flag = mapped.clone();
self.read
.slice(..bytes)
.map_async(wgpu::MapMode::Read, move |r| {
flag.store(r.is_ok(), Ordering::Release)
});
self.stage = Stage::Reading(mapped, bytes);
}
Stage::Reading(mapped, bytes) if mapped.load(Ordering::Acquire) => {
let bytes = *bytes;
self.collect(bytes);
self.stage = Stage::Idle;
}
_ => {}
}
if matches!(self.stage, Stage::Idle) {
self.names.clear();
self.stage = Stage::Recording;
}
}
/// Whether the frame being recorded is timed.
pub fn recording(&self) -> bool {
matches!(self.stage, Stage::Recording)
}
/// Timestamp writes for the pass `name`, in a timed frame.
pub fn writes(&mut self, name: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(stamps(&self.queries, i))
}
/// The index of the timed frame's next pass, `name`.
fn push(&mut self, name: &str) -> Option<u32> {
let i = self.names.len() as u32;
if !matches!(self.stage, Stage::Recording) || i >= CAPACITY {
return None;
}
self.names.push(name.to_owned());
Some(i)
}
/// A pass that ends once everything drawn into `target` before it has, in a timed frame:
/// the next pass counts from there, not from when the GPU queued it behind them.
pub fn mark(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
(target, format): super::timing::Target,
) {
if self.marker.as_ref().is_none_or(|(f, _)| *f != format) {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack timing mark"),
source: wgpu::ShaderSource::Wgsl(super::timing::MARKER.into()),
});
let pipeline = super::timing::end_pipeline(device, &module, format);
self.marker = Some((format, pipeline));
}
// An unnamed pass: its end counts, it is never listed.
let Some(i) = self.push("") else {
return;
};
let (_, pipeline) = self.marker.as_ref().expect("made above");
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack timing mark"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: Some(stamps(&self.queries, i)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_scissor_rect(0, 0, 1, 1);
pass.draw(0..3, 0..1);
}
/// After the frame is submitted.
pub fn submitted(&mut self, queue: &wgpu::Queue) {
if !matches!(self.stage, Stage::Recording) {
return;
}
if self.names.is_empty() {
self.stage = Stage::Idle;
return;
}
let done = Arc::new(AtomicBool::new(false));
let flag = done.clone();
queue.on_submitted_work_done(move || flag.store(true, Ordering::Release));
self.stage = Stage::Running(done);
}
fn collect(&mut self, bytes: u64) {
let slice = self.read.slice(..bytes);
let Ok(data) = slice.get_mapped_range() else {
return;
};
let stamps: Vec<u64> = data
.as_chunks::<8>()
.0
.iter()
.map(|b| u64::from_le_bytes(*b))
.collect();
drop(data);
self.read.unmap();
let times = pass_times(&stamps, self.period_ns);
for (name, ms) in self.names.iter().zip(times) {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
let i = match self.samples.iter().position(|s| s.0 == *name) {
Some(i) => i,
None => {
self.samples.push((name.clone(), VecDeque::new()));
self.samples.len() - 1
}
};
let window = &mut self.samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
self.reports += 1;
if self.log && self.reports.is_multiple_of(REPORT) {
let medians = self.medians();
let total: f32 = medians.iter().map(|m| m.1).sum();
let parts: Vec<String> = medians
.iter()
.map(|(n, ms)| format!("{n} {ms:.3}"))
.collect();
log::info!(
"shader pack GPU time per pass (median ms of the last {WINDOW} timed frames): {} = {total:.3}",
parts.join(", ")
);
}
}
/// Each pass's median time in milliseconds over its last timed frames, in the order the
/// passes first ran.
pub fn medians(&self) -> Vec<(String, f32)> {
self.samples
.iter()
.filter(|s| !s.1.is_empty())
.map(|(name, v)| {
let mut v: Vec<f32> = v.iter().copied().collect();
v.sort_by(f32::total_cmp);
(name.clone(), v[v.len() / 2])
})
.collect()
}
}
/// Pass `i`'s beginning and end timestamps.
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
wgpu::RenderPassTimestampWrites {
query_set: queries,
beginning_of_pass_write_index: Some(2 * i),
end_of_pass_write_index: Some(2 * i + 1),
}
}
/// Each pass's time in milliseconds from its (beginning, end) timestamps, `None` for a pass
/// the GPU left unstamped. Passes overlap on tiled GPUs (a pass begins before the one before it
/// ends): a pass costs the time from the later of its beginning and the previous pass's end.
fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
let mut previous_end = 0;
stamps
.as_chunks::<2>()
.0
.iter()
.map(|&[begin, end]| {
if begin == 0 || end < begin {
return None;
}
let from = begin.max(previous_end).min(end);
previous_end = end;
Some((end - from) as f32 * period_ns / 1e6)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::pass_times;
#[test]
fn overlapping_passes_cost_from_the_previous_end() {
// Pass 2 begins before pass 1 ends; pass 3 was never stamped.
let stamps = [1_000_000, 3_000_000, 2_000_000, 5_000_000, 0, 0];
assert_eq!(pass_times(&stamps, 1.0), [Some(2.0), Some(2.0), None]);
}
}
@@ -1,521 +0,0 @@
//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class
//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`).
//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use super::Frame;
/// What the game passes for the pack's uniforms, beyond what `Frame` carries.
#[derive(Clone, Copy, Debug, Default)]
pub struct PackInputs {
/// `Level.getDayTime`.
pub day_time: i64,
/// The interpolated rain and thunder levels, 0..1.
pub rain: f32,
pub thunder: f32,
/// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow.
pub eye_in: i32,
pub blindness: f32,
/// Block and sky light at the camera, 0..15.
pub eye_light: [i32; 2],
pub hide_gui: bool,
/// The brightness option, 0..1.
pub brightness: f32,
/// The biome at the camera (3.4): registry id, `CAT_*`, `PPT_*`, temperature, downfall.
pub biome: i32,
pub biome_category: i32,
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
}
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
F([f32; 16]),
I([i32; 3]),
}
impl Value {
fn f(v: &[f32]) -> Self {
let mut a = [0.0; 16];
a[..v.len()].copy_from_slice(v);
Value::F(a)
}
fn i(v: &[i32]) -> Self {
let mut a = [0; 3];
a[..v.len()].copy_from_slice(v);
Value::I(a)
}
/// Writes the value as the type the pack declared, at `offset` of a std140 block.
pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) {
let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']);
let (floats, integers): ([f32; 16], [i32; 16]) = match *self {
Value::F(f) => (f, f.map(|v| v as i32)),
Value::I(i) => {
let mut f = [0.0; 16];
let mut v = [0; 16];
for k in 0..3 {
f[k] = i[k] as f32;
v[k] = i[k];
}
(f, v)
}
};
let count = match ty {
"mat4" => 16,
"mat3" => {
// std140: three columns, each padded to a vec4, from the mat4's upper left.
for c in 0..3 {
let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]);
put(out, offset + c * 16, bytemuck::cast_slice(&column));
}
return;
}
t => match t.as_bytes().last() {
Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize,
_ => 1,
},
};
if ints {
put(out, offset, bytemuck::cast_slice(&integers[..count]));
} else {
put(out, offset, bytemuck::cast_slice(&floats[..count]));
}
}
}
fn put(out: &mut [u8], offset: usize, bytes: &[u8]) {
if let Some(dst) = out.get_mut(offset..offset + bytes.len()) {
dst.copy_from_slice(bytes);
}
}
/// The uniforms provided, in the order of [`FrameUniforms::values`].
pub const NAMES: &[&str] = &[
"cameraPosition",
"previousCameraPosition",
"eyeAltitude",
"upPosition",
"sunPosition",
"moonPosition",
"shadowLightPosition",
"sunAngle",
"shadowAngle",
"worldTime",
"worldDay",
"moonPhase",
"frameCounter",
"frameTime",
"frameTimeCounter",
"viewWidth",
"viewHeight",
"aspectRatio",
"near",
"far",
"rainStrength",
"wetness",
"thunderStrength",
"fogColor",
"skyColor",
"fogMode",
"fogStart",
"fogEnd",
"fogDensity",
"fogShape",
"isEyeInWater",
"blindness",
"nightVision",
"darknessFactor",
"darknessLightFactor",
"screenBrightness",
"hideGUI",
"eyeBrightness",
"eyeBrightnessSmooth",
"heldItemId",
"heldItemId2",
"heldBlockLightValue",
"heldBlockLightValue2",
"renderStage",
"biome",
"biome_category",
"biome_precipitation",
"temperature",
"rainfall",
"gbufferModelView",
"gbufferModelViewInverse",
"gbufferProjection",
"gbufferProjectionInverse",
"gbufferPreviousModelView",
"gbufferPreviousProjection",
"atlasSize",
"shadowModelView",
"shadowModelViewInverse",
"shadowProjection",
"shadowProjectionInverse",
];
/// Values carried from frame to frame.
pub struct FrameUniforms {
pub values: Vec<Value>,
/// The block atlas's size (`atlasSize`), 0 without one.
pub atlas: [u32; 2],
start: std::time::Instant,
last: Option<std::time::Instant>,
frame: u32,
previous: Option<(Vec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
impl Default for FrameUniforms {
fn default() -> Self {
Self {
values: Vec::new(),
atlas: [0; 2],
start: std::time::Instant::now(),
last: None,
frame: 0,
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
shadow: Shadow::default(),
}
}
}
/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees;
/// `sunPathRotation` (`tilt`, degrees) then turns the path about the world's X axis (7.1, OQ-6).
fn body(angle: f32, tilt: f32) -> Vec3 {
(tilt_matrix(tilt)
* Mat4::from_rotation_y((-90.0f32).to_radians())
* Mat4::from_rotation_x(angle.to_radians()))
.transform_vector3(Vec3::new(0.0, 100.0, 0.0))
}
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
/// positive angle moves the noon sun toward +Z (south).
pub fn tilt_matrix(tilt: f32) -> Mat4 {
Mat4::from_rotation_x(tilt.to_radians())
}
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
pub fn shadow_light(frame: &Frame, shadow: &Shadow) -> Vec3 {
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
let angle = if sun_angle < 0.5 {
frame.sky.sun_angle
} else {
frame.sky.moon_angle
};
body(angle, shadow.sun_path_rotation).normalize()
}
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
// GL's depth -w..w to wgpu's 0..w.
let depth = Mat4::from_cols(
Vec4::X,
Vec4::Y,
Vec4::new(0.0, 0.0, 0.5, 0.0),
Vec4::new(0.0, 0.0, 0.5, 1.0),
);
depth * gl * view
}
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
/// default near and far planes (OQ-6).
const SHADOW_EYE: f64 = 128.0;
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
let light = light.as_dvec3();
let up = if light.z.abs() > 0.99 {
DVec3::Y
} else {
DVec3::Z
};
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
let at = rotation.transform_vector3(camera);
let step = f64::from(shadow.interval);
let snapped = if step > 0.0 {
(at / step).floor() * step
} else {
at
};
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
let (near, far) = (shadow.near, shadow.far);
let projection = match shadow.fov {
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
None => {
let d = shadow.distance;
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
}
};
(view, projection)
}
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
target + (value - target) * 0.5f32.powf(dt / half_life)
}
impl FrameUniforms {
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
self.last = Some(now);
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = Mat4::perspective_rh_gl(
frame.fov_degrees.to_radians(),
aspect,
0.05,
frame.depth_far,
) * frame.bob
* frame.spin;
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
self.previous = Some((camera, view, projection));
let to_view = |v: Vec3| view.transform_vector3(v);
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
let day = sun_angle < 0.5;
let tilt = self.shadow.sun_path_rotation;
let sun = to_view(body(frame.sky.sun_angle, tilt));
let moon = to_view(body(frame.sky.moon_angle, tilt));
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
let half_life = if inputs.rain > self.wetness {
600.0
} else {
200.0
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => {
let (start, end) = if f.environmental_end < f.render_distance_end {
(f.environmental_start, f.environmental_end)
} else {
(f.render_distance_start, f.render_distance_end)
};
([f.color[0], f.color[1], f.color[2]], start, end)
}
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let light = shadow_light(frame, &self.shadow);
let (shadow_view, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
let seconds = (now - self.start).as_secs_f32() % 3600.0;
let value = |name: &str| -> Value {
let v3 = |v: Vec3| Value::f(&v.to_array());
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
match name {
"cameraPosition" => v3(camera),
"previousCameraPosition" => v3(previous_camera),
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
"shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]),
"worldTime" => Value::i(&[day_time]),
"worldDay" => Value::i(&[world_day as i32]),
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
"frameCounter" => Value::i(&[self.frame as i32]),
"shadowModelView" => m4(shadow_view),
"shadowModelViewInverse" => m4(shadow_view.inverse()),
"shadowProjection" => m4(shadow_projection),
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
"frameTime" => Value::f(&[dt]),
"frameTimeCounter" => Value::f(&[seconds]),
"viewWidth" => Value::f(&[size[0] as f32]),
"viewHeight" => Value::f(&[size[1] as f32]),
"aspectRatio" => Value::f(&[aspect]),
"near" => Value::f(&[0.05]),
"far" => Value::f(&[frame.distance]),
"rainStrength" => Value::f(&[inputs.rain]),
"wetness" => Value::f(&[self.wetness]),
"thunderStrength" => Value::f(&[inputs.thunder]),
"fogColor" => Value::f(&fog_color),
"skyColor" => Value::f(&frame.sky.sky_color),
// GL_LINEAR: vanilla's fog is linear.
"fogMode" => Value::i(&[9729]),
"fogStart" => Value::f(&[fog_start]),
"fogEnd" => Value::f(&[fog_end]),
"fogDensity" => Value::f(&[1.0]),
"fogShape" => Value::i(&[0]),
"isEyeInWater" => Value::i(&[inputs.eye_in]),
"blindness" => Value::f(&[inputs.blindness]),
"nightVision" => Value::f(&[frame.night_vision]),
"darknessFactor" => Value::f(&[frame.darkness.0]),
"darknessLightFactor" => Value::f(&[frame.darkness.1]),
"screenBrightness" => Value::f(&[inputs.brightness]),
"hideGUI" => Value::i(&[inputs.hide_gui as i32]),
"eyeBrightness" => Value::i(&eye.map(|v| v as i32)),
"biome" => Value::i(&[inputs.biome]),
"biome_category" => Value::i(&[inputs.biome_category]),
"biome_precipitation" => Value::i(&[inputs.biome_precipitation]),
"temperature" => Value::f(&[inputs.temperature]),
"rainfall" => Value::f(&[inputs.rainfall]),
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
"gbufferModelView" => m4(view),
"gbufferModelViewInverse" => m4(view.inverse()),
"gbufferProjection" => m4(projection),
"gbufferProjectionInverse" => m4(projection.inverse()),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"heldItemId" => Value::i(&[inputs.held_items[0]]),
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
}
};
self.values.clear();
self.values.extend(NAMES.iter().map(|n| value(n)));
self.frame = (self.frame + 1) % 720_720;
}
/// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`).
pub fn fog(&self) -> [Vec4; 2] {
let get = |name| match index(name).and_then(|i| self.values.get(i)) {
Some(Value::F(f)) => *f,
_ => [0.0; 16],
};
let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]);
[
Vec4::new(color[0], color[1], color[2], 1.0),
Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)),
]
}
}
impl FrameUniforms {
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
/// `NAMES`' values, in `custom.uniforms()` order.
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
self.values.truncate(NAMES.len());
let values = &self.values;
let builtin = |name: &str| {
let v = values.get(index(name)?)?;
let n = components(name);
Some(match *v {
Value::F(f) => horizon_shaders::Val::vector(&f.map(f64::from)[..n]),
Value::I(i) => horizon_shaders::Val::vector(&i.map(f64::from)[..n.min(3)]),
})
};
let dt = match index("frameTime").and_then(|i| values.get(i)) {
Some(Value::F(f)) => f64::from(f[0]),
_ => 0.0,
};
let out = custom.eval(dt, &builtin);
self.values
.extend(out.iter().map(|v| Value::f(&v.v.map(|x| x as f32)[..v.n])));
}
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
"cameraPosition"
| "previousCameraPosition"
| "upPosition"
| "sunPosition"
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
}
/// The position of `name` in [`NAMES`].
pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_follow_the_declared_type() {
let mut out = vec![0u8; 64];
Value::i(&[7]).write("float", &mut out, 0);
assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0);
Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4);
assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2);
Value::i(&[1]).write("bool", &mut out, 16);
assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1);
Value::f(&[1.0; 16]).write("mat4", &mut out, 60);
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
#[test]
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
}
#[test]
fn the_shadow_camera_looks_down_from_the_light_in_steps() {
let shadow = Shadow::default();
let mut camera = DVec3::new(1000.3, 70.0, -20.7);
// Noon: the light is straight up.
let (view, projection) = shadow_camera(Vec3::Y, camera, &shadow);
let at = |p: Vec3| view.transform_point3(p);
// Straight down: a block below the player is farther from the light.
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
// The player is less than a step from the centre, 128 blocks from the light.
let centre = at(Vec3::ZERO);
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
// Moving within a step keeps the world where it was in the map.
camera.x = 1000.6;
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
let world = Vec3::new(5.0, -3.0, 9.0);
let shift = Vec3::new(0.3, 0.0, 0.0);
assert!(
moved
.transform_point3(world - shift)
.abs_diff_eq(at(world), 1e-3)
);
// shadowDistance blocks reach the map's edge.
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
}
}
Loaded 100 of 2305 files, more files were not shown because too many files have changed in this diff. Show more