Merge main and remove remaining RAM limit features

Remove the hard allocation ceiling and glass launcher slider added since the original revert. Keep read-only memory diagnostics and unrelated upstream changes.

Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
rdbtCVSandOpenAI Codex committed 2026-11-04 10:18:52 +00:00
commit c25f708490
1586 files changed
+343405 -22137

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
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.
animation) must be reached from `game/remote*.rs`.
- 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.
+2 -2
View File
@@ -9,9 +9,9 @@ permissions:
actions: write
jobs:
cancel:
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# One of our small runners (horizon-light), out of GitHub's pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
+107
View File
@@ -0,0 +1,107 @@
name: cargo-horizon-mod
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120): binstall reads
# [package.metadata.binstall] from crates/cargo-horizon-mod/Cargo.toml and downloads the archive this workflow
# attaches to the tag `cargo-horizon-mod-v<version>`, whose version must be the manifest's. The targets come from
# tools/horizon_mod_binstall.py, the same file the manifest's template is checked against, so a platform added there
# is built here and one nobody publishes is caught before it ships. A pull request builds the archives and publishes
# nothing.
on:
push:
tags: ['cargo-horizon-mod-v*']
pull_request:
paths:
- 'crates/cargo-horizon-mod/**'
- 'tools/horizon_mod_binstall.py'
- '.github/workflows/cargo-horizon-mod.yml'
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
needs: plan
name: build ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Build
run: cargo build --release --locked -p cargo-horizon-mod --target ${{ matrix.target }}
- name: It starts
if: matrix.run
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
out=$("$binary" 2>&1 || true)
case "$out" in
*"usage: cargo horizon-mod"*) ;;
*) echo "$out"; echo "::error::$binary does not start"; exit 1 ;;
esac
- name: Package
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
retention-days: 3
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: cargo-horizon-mod-*
path: dist
merge-multiple: true
- name: The tag and the manifest name the same release and archives
run: python3 tools/horizon_mod_binstall.py dist "$GITHUB_REF_NAME"
- name: Checksums
run: |
set -euo pipefail
cd dist
for file in *.tar.gz; do sha256sum "$file" | cut -d' ' -f1 > "$file.sha256"; done
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes="\`cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod\`"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* --latest=false --title "cargo-horizon-mod ${GITHUB_REF_NAME#cargo-horizon-mod-v}" --notes "$notes"
fi
+31 -11
View File
@@ -27,13 +27,18 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -63,6 +68,16 @@ jobs:
else
python tools/check_public_tree.py --history
fi
# Our runners as GitHub's: some Python tests compile Rust with a bare `rustc` (GitHub's image has a default
# toolchain, our runners' own rustup homes may not), and the GPU tests find no adapter there, so they return
# early. Ours have Mesa's software Vulkan for parity; a backend Linux lacks keeps the tests from it, so a pull
# request's verify doesn't depend on which pool took it (#8627: a GPU test that flakes on lavapipe).
- name: Our runners as GitHub's (a default Rust toolchain, no GPU)
if: runner.environment != 'github-hosted'
run: |
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -96,14 +111,19 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# 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 mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# 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: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -144,8 +164,8 @@ jobs:
# 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
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# A git diff of this repository's own branch: one of our small runners (horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+4 -4
View File
@@ -29,8 +29,8 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@@ -56,8 +56,8 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+9
View File
@@ -185,6 +185,15 @@ jobs:
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
run: |
set -euo pipefail
+22 -1
View File
@@ -15,6 +15,8 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -26,6 +28,7 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
@@ -174,7 +177,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
@@ -240,6 +243,18 @@ jobs:
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
if [[ "$RUNNER_OS" == "Windows" ]]; then
setup="$RUNNER_TEMP/setup-smoke"
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
installed="$setup/Horizon Launcher/horizon-launcher.exe"
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
"$installed" uninstall --quiet
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
rm -rf "$setup"
fi
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -275,6 +290,12 @@ jobs:
fi
fi
ls -l out
- name: Setup exe (Windows)
# The launcher's binary under another name: started with no arguments it shows the per-user
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
concurrency:
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ concurrency:
jobs:
check:
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script, Mojang's public manifest.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+2 -1
View File
@@ -253,7 +253,8 @@ jobs:
# 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
# One comment, no checkout and no secret: one of our small runners (label horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
issues: write
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+14 -3
View File
@@ -22,17 +22,24 @@ env:
jobs:
bench:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md): the branch and the base on the same machine, as
# the comparison needs. SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
- name: Bench the branch
@@ -46,11 +53,15 @@ jobs:
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
# Our runners keep the folder beside the checkout between jobs.
git worktree remove --force ../base 2> /dev/null || rm -rf ../base
git worktree prune
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
target=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/target}
(cd ../base && CARGO_TARGET_DIR="$target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
concurrency:
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script only, as above.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+16 -15
View File
@@ -55,6 +55,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `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-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `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`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -103,6 +104,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ 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.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
@@ -143,7 +148,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|---|---|
| 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` |
| 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/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `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` |
@@ -174,28 +179,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- 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
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
- A server's players run through the shared gameplay code: `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
## Single player runs on a server thread
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`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
`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 (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
Open `[server-thread]` issues list the gaps left.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+36 -34
View File
@@ -5,7 +5,8 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
welcome on [Discord](https://discord.gg/playhorizon).
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.
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
@@ -14,23 +15,25 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
- **Commands:**
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -41,11 +44,10 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
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.
- **No game files in the repo.** 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, or code whose license allows it
@@ -54,24 +56,23 @@ another project's source may also be read and used, **only when that project's l
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. 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.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
## Before asking for review
@@ -87,8 +88,8 @@ your change can affect. Without the vanilla data on your machine it lists those
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies.
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
@@ -96,7 +97,8 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
@@ -122,8 +124,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the 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.
`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)).
Generated
+112 -2
View File
@@ -329,6 +329,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -367,6 +373,7 @@ version = "0.1.0"
dependencies = [
"horizon-mods",
"serde_json",
"zip",
]
[[package]]
@@ -1210,6 +1217,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -1266,6 +1279,7 @@ name = "horizon-assets"
version = "0.1.0"
dependencies = [
"horizon-core",
"log",
"png",
"serde",
"serde_json",
@@ -1318,10 +1332,12 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1399,6 +1415,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1412,6 +1429,7 @@ dependencies = [
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"mio",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -1428,6 +1446,7 @@ dependencies = [
"ureq",
"wgpu",
"winit",
"xxhash-rust",
"zip",
]
@@ -1449,8 +1468,10 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
@@ -1460,7 +1481,9 @@ dependencies = [
"horizon-theme",
"horizon-threads",
"horizon-world",
"image-webp",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1477,6 +1500,7 @@ dependencies = [
"wgpu",
"winit",
"zip",
"zune-jpeg",
]
[[package]]
@@ -1484,6 +1508,7 @@ name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-protocol",
"horizon-world",
]
@@ -1495,6 +1520,18 @@ dependencies = [
"libm",
]
[[package]]
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"glam",
"png",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1542,6 +1579,32 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-api-gen"
version = "0.1.0"
dependencies = [
"heck",
"quote",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "horizon-mod-api-gen-tests"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1574,6 +1637,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -1613,6 +1677,7 @@ dependencies = [
"horizon-core",
"horizon-world",
"ipconfig",
"log",
"serde_json",
"sha1",
"sha2",
@@ -1642,10 +1707,12 @@ dependencies = [
"ab_glyph",
"base64",
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
@@ -1733,6 +1800,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
@@ -1879,6 +1947,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2102,8 +2180,6 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
@@ -2241,6 +2317,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3102,6 +3190,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -5119,6 +5213,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"horizon-theme",
]
@@ -5281,3 +5376,18 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
+10 -1
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
@@ -12,11 +12,14 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
@@ -28,6 +31,8 @@ members = [
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
"crates/horizon-mod-api-gen",
"crates/horizon-mod-api-gen/dispatch-tests",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
@@ -65,6 +70,10 @@ large_enum_variant = "allow"
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+3 -3
View File
@@ -7,7 +7,7 @@
<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%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+4 -13
View File
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+89 -12
View File
@@ -14,6 +14,18 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -27,6 +39,17 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
@@ -41,19 +64,17 @@ trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
instance icons.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
@@ -91,3 +112,59 @@ licensed under the MIT license:
> 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.
## FusionExamples
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
texture templates (`templates/template <layout>.png`) of
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
licensed under the MIT license:
> Copyright (c) 2024 Martijn van den Brand
>
> 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.
## BlueMap
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
`settings.json`, at commit
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
BlueMap is licensed under the MIT license:
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
> Copyright (c) contributors
>
> 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.
@@ -0,0 +1,27 @@
# Particle culling review measurements — 2026-11-02
Ordinary-scene captures and measurements for PR #7054. The scene contains 24 mobs, about 9,400–10,100 rain particles, and about 50–100 campfire smoke particles. Two opposite-order passes were run with 2 s warmup and 8 s measurement per condition. Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap.
These are means of the two samples. CPU is the harness's average CPU time per frame; p99 is frame time. The normal scene is not expected to show large gains because most particles are visible.
| Condition | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, culling Off | 88.39 | 1.930 | 23.85 |
| Particle branch, culling Off | 88.55 | 1.828 | 24.88 |
| Main, Entity Culling Synchronous | 89.12 | 1.887 | 23.07 |
| Particle branch, Entity Culling Synchronous, Particle Culling Off | 89.06 | 1.897 | 25.18 |
| Particle Culling Synchronous | 88.87 | 1.774 | 24.94 |
| Particle Culling Asynchronous* | 89.00 | 1.754 | 24.90 |
*The asynchronous samples predate the final switch to a dedicated particle worker. They are retained only as historical context and are not a measurement of the final worker implementation. No FPS improvement is claimed from these ordinary-scene runs. The Off path delegates directly to `render`; `disabled_culled_render_matches_the_regular_particle_path` checks that it produces the same vertices.
The FPS and CPU differences are within run-to-run variability for this GPU-limited scene. The first four conditions are the reviewer's requested Main-versus-branch default and existing synchronous-mode comparisons. Particle Sync/Async rows are branch-internal mode comparisons.
## Captures
The screenshots show the same ordinary test world and FPS overlay:
- `main-off.png`
- `particle-off.png`
- `particle-sync.png`
- `particle-async.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

+11
View File
@@ -6,9 +6,20 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
# `cargo-horizon-mod`'s workflow attaches to the `cargo-horizon-mod-v<version>` release for the runner's target,
# holding the binary at its root. tools/horizon_mod_binstall.py expands it for the workflow's targets.
[package.metadata.binstall]
pkg-url = "https://github.com/horizon-rust/horizon/releases/download/cargo-horizon-mod-v{ version }/{ name }-{ target }.tar.gz"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
[dev-dependencies]
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+133 -7
View File
@@ -1,7 +1,8 @@
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
//!
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `new <path> [--name "Name"]`: a content mod from the template; with
//! `--template native [--horizon <commit>]`, a native trait mod's bundle.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -32,7 +33,7 @@ fn main() -> ExitCode {
Some("run") => run(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] [--template content|native [--horizon <commit>]] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -74,6 +75,11 @@ fn new(args: &[String]) -> Result<(), String> {
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
match flag(args, "--template") {
None | Some("content") => {}
Some("native") => return new_native(dir, &id, flag(args, "--horizon"), fill),
Some(_) => return Err("--template is content or native".into()),
}
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let files = [
(
@@ -114,11 +120,6 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
if flag(args, "--template").is_some_and(|t| t != "content") {
return Err(
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
);
}
for (file, text) in files {
let file = fill(file);
let file = file.as_str();
@@ -137,6 +138,67 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// A native trait mod's bundle in `dir` (docs/native-mods.md, "Trait mods"): its
/// `horizon-mixin.toml` and an `[api]` crate with one hook. It is what the launcher
/// builds (`native-build <dir>`) and packs as `mixins/<id>/` of a `.hnmod`
/// (docs/api/mod-files.md). `horizon` pins the Horizon commit it is made for.
fn new_native(
dir: &Path,
id: &str,
horizon: Option<&str>,
fill: impl Fn(&str) -> String,
) -> Result<(), String> {
// `[mod] id`, as `horizon-native.json` needs it: the template keeps to its characters.
if id.len() > 64 {
return Err(format!("mod id {id}: 64 characters at most"));
}
let (compatibility, rev) = match horizon {
Some(commit) if horizon_mods::index::native::revision(commit) => (
format!("[compatibility]\nhorizon_revision = \"{commit}\"\n"),
format!(", rev = \"{commit}\""),
),
Some(commit) => {
return Err(format!(
"--horizon {commit}: a Horizon commit is 40 lowercase hex digits"
));
}
// Unpinned, it builds on the instance's own Horizon.
None => (
"# The Horizon commit it is made for; unpinned, it builds on the instance's own.\n\
# [compatibility]\n# horizon_revision = \"<40 hex digits>\"\n"
.to_owned(),
String::new(),
),
};
let files = [
(
"horizon-mixin.toml",
include_str!("../template/native/horizon-mixin.toml"),
),
(
"api/Cargo.toml",
include_str!("../template/native/api.toml"),
),
("api/src/lib.rs", include_str!("../template/native/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
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())?;
let text = fill(text)
.replace("{compatibility}", &compatibility)
.replace("{rev}", &rev);
std::fs::write(&to, text).map_err(|e| format!("{}: {e}", to.display()))?;
}
println!(
"Created {} (native mod id {id}). Next: horizon-launcher native-build {} --instance <instance> --trust-native-code",
dir.display(),
dir.display()
);
Ok(())
}
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
/// manifest and the files in `assets/` and `data/`.
fn build() -> Result<PathBuf, String> {
@@ -272,4 +334,68 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_native_template_packs_into_an_hnmod_the_registry_accepts() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-mod-native-{}", std::process::id()));
let path = dir.join("machine-mod");
let commit = "0123456789abcdef0123456789abcdef01234567";
let args = [
path.display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
commit.into(),
];
new(&args).unwrap();
let api = std::fs::read_to_string(path.join("api/Cargo.toml")).unwrap();
assert!(api.contains(&format!("rev = \"{commit}\"")), "{api}");
// Packed as the launcher packs a mod folder: the manifest, then the bundle under
// `mixins/<id>/` (docs/api/mod-files.md).
let manifest = serde_json::json!({
"schema": 1, "id": "machine_mod", "version": "0.1.0", "game": "26.3",
"horizon": commit, "mixins": "b".repeat(40),
});
let hnmod = dir.join("machine_mod.hnmod");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&hnmod).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("horizon-native.json", options).unwrap();
zip.write_all(manifest.to_string().as_bytes()).unwrap();
for file in [
"horizon-mixin.toml",
"api/Cargo.toml",
"api/src/lib.rs",
".gitignore",
] {
assert!(horizon_mods::index::native::allowed(file), "{file}");
zip.start_file(format!("mixins/machine_mod/{file}"), options)
.unwrap();
zip.write_all(&std::fs::read(path.join(file)).unwrap())
.unwrap();
}
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
dir.join("bad").display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
"main".into(),
];
assert!(new(&bad).unwrap_err().contains("40 lowercase hex"));
let free = dir.join("free");
new(&[
free.display().to_string(),
"--template".into(),
"native".into(),
])
.unwrap();
let mixin = std::fs::read_to_string(free.join("horizon-mixin.toml")).unwrap();
assert!(!mixin.contains("\nhorizon_revision"), "{mixin}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,9 @@
[package]
name = "mod-{id}"
version = "0.1.0"
edition = "2024"
[dependencies]
# The launcher points this at the Horizon source it builds the game from. Any other
# dependency must be a crate and version in Horizon's Cargo.lock.
horizon-client = { git = "https://github.com/horizon-rust/horizon"{rev} }
@@ -0,0 +1,9 @@
# {name}: a native trait mod (docs/native-mods.md, "Trait mods"). Build it with
# `horizon-launcher native-build <this folder> --instance <instance> --trust-native-code`.
[mod]
id = "{id}"
version = "0.1.0"
{compatibility}
[api]
path = "api"
@@ -0,0 +1,18 @@
//! The trait mod: the launcher links this crate into the game and calls `hooks()`. Each
//! method of `Hooks` is one of Horizon's hook points; implement the ones the mod needs
//! (docs/native-mods.md, "Trait mods").
use horizon_client::mod_api::{Hooks, Host, PlayerLoggedInEvent, Text};
pub fn hooks() -> Box<dyn Hooks> {
Box::new(Mod)
}
struct Mod;
impl Hooks for Mod {
/// A player joined the server (NeoForge's `PlayerLoggedInEvent`): greet them.
fn player_logged_in(&self, host: &mut dyn Host, event: &mut PlayerLoggedInEvent<'_>) {
let line = format!("{id} says hello, {}!", event.name);
host.tell(event.player, Text::literal(line));
}
}
+1
View File
@@ -8,6 +8,7 @@ description = "Horizon version-specific model and resource decoding"
[dependencies]
horizon-core = { path = "../horizon-core" }
log = "0.4"
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["raw_value"] }
+350 -4
View File
@@ -1,5 +1,10 @@
//! Cuboid geometry in block units, including blockstate rotation and UV locking.
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
use crate::{
json::*,
model::*,
obj::{MtlTexture, ObjFace, ObjModel},
resolve::{ResolvedCuboidModel, ResolvedLoader},
};
mod transform;
pub use transform::BlockModelTransform;
@@ -13,7 +18,40 @@ pub struct ModelQuad {
pub material: Material,
pub tint_index: i32,
pub shade_direction_override: Option<Direction>,
/// The face's or element's `light_emission`.
pub light_emission: u8,
/// Least (block, sky) light the quad is drawn with: its emission, raised by `neoforge_data`.
pub light: [u8; 2],
/// `neoforge_data` colour (ARGB) multiplied into the quad's tint.
pub color: Option<i32>,
/// `neoforge_data` override of the model's ambient occlusion for this quad.
pub ambient_occlusion: Option<bool>,
pub render_type: Option<RenderType>,
/// False for a loader quad that takes no directional shading.
pub shade: bool,
}
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
Solid,
Cutout,
CutoutMipped,
Translucent,
Tripwire,
}
impl RenderType {
/// Accepts a bare or `minecraft:` name; others (mods' own types) are `None`.
pub fn by_name(name: &str) -> Option<Self> {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"solid" => Some(Self::Solid),
"cutout" => Some(Self::Cutout),
"cutout_mipped" => Some(Self::CutoutMipped),
"translucent" => Some(Self::Translucent),
"tripwire" => Some(Self::Tripwire),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
@@ -186,6 +224,17 @@ fn bake_elements(
skip_flat: bool,
) -> Result<Vec<ModelQuad>> {
let mut output = Vec::new();
bake_into(model, transform, skip_flat, &mut output)?;
Ok(output)
}
/// The elements, then what the model's loader adds.
fn bake_into(
model: &ResolvedCuboidModel,
transform: BlockModelTransform,
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for element in model.elements() {
let element = element
.as_ref()
@@ -207,11 +256,108 @@ fn bake_elements(
force_translucent: false,
});
output.push(bake_face(
element, face, *direction, matrix, material, transform,
element,
face,
*direction,
matrix,
material,
transform,
render_type,
)?);
}
}
Ok(output)
match model.loader() {
Some(ResolvedLoader::Composite(children)) => {
for child in children {
bake_into(child, transform, skip_flat, output)?;
}
}
Some(ResolvedLoader::Obj {
model: Some(obj),
flip_v,
shade,
hidden,
}) => bake_obj(model, obj, *flip_v, *shade, hidden, transform, output)?,
Some(ResolvedLoader::Obj { model: None, .. }) | None => {}
}
Ok(())
}
/// Obj faces are unculled quads in block units; the blockstate transform moves them whole.
fn bake_obj(
model: &ResolvedCuboidModel,
obj: &ObjModel,
flip_v: bool,
shade: bool,
hidden: &std::collections::BTreeSet<String>,
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
output.push(obj_quad(
face,
Material {
sprite: identifier(sprite)?,
force_translucent: false,
},
flip_v,
shade,
render_type,
transform,
));
continue;
}
Some(MtlTexture::Slot(slot)) => slot,
None => &face.material,
};
let material = if slot.is_empty() {
None
} else {
model.material(slot)?
};
let material = material.cloned().unwrap_or(Material {
sprite: "minecraft:missingno".to_owned(),
force_translucent: false,
});
output.push(obj_quad(
face,
material,
flip_v,
shade,
render_type,
transform,
));
}
Ok(())
}
fn obj_quad(
face: &ObjFace,
material: Material,
flip_v: bool,
shade: bool,
render_type: Option<RenderType>,
transform: BlockModelTransform,
) -> ModelQuad {
let positions = face.vertices.map(|v| transform.position(v.position));
ModelQuad {
positions,
uv: face
.vertices
.map(|v| [v.uv[0], if flip_v { 1.0 - v.uv[1] } else { v.uv[1] }]),
facing: facing(&positions).unwrap_or(Direction::Up),
cull: None,
material,
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type,
shade,
}
}
/// `FaceBakery.bakeQuad` for a quad with explicit corners and unit-atlas UVs
/// (`uv` in 0..16 texels), as `ItemModelGenerator` builds its layers.
@@ -230,6 +376,7 @@ pub fn bake_flat_face(
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
};
let face = Face {
cull: None,
@@ -237,6 +384,8 @@ pub fn bake_flat_face(
texture: String::new(),
uv: Some(uv),
rotation: 0,
light_emission: None,
neoforge_data: None,
};
bake_face(
&element,
@@ -245,6 +394,7 @@ pub fn bake_flat_face(
None,
material,
BlockModelTransform::new([0; 3], false),
None,
)
}
fn bake_face(
@@ -254,6 +404,7 @@ fn bake_face(
matrix: Option<ElementMatrix>,
material: Material,
transform: BlockModelTransform,
render_type: Option<RenderType>,
) -> Result<ModelQuad> {
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
@@ -303,6 +454,11 @@ fn bake_face(
uv.swap(index, found);
}
}
let data = face
.neoforge_data
.unwrap_or_default()
.over(element.neoforge_data.unwrap_or_default());
let light_emission = face.light_emission.unwrap_or(element.light_emission);
Ok(ModelQuad {
positions,
uv,
@@ -311,6 +467,196 @@ fn bake_face(
material,
tint_index: face.tint_index,
shade_direction_override: element.shade_direction_override,
light_emission: element.light_emission,
light_emission,
light: [data.block_light, data.sky_light].map(|l| l.unwrap_or(0).max(light_emission)),
color: data.color,
ambient_occlusion: data.ambient_occlusion,
render_type,
shade: true,
})
}
#[cfg(test)]
mod loader_tests {
use super::*;
use crate::resolve::{AssetReader, ModelLibrary};
use std::{collections::BTreeMap, sync::Arc};
struct Files(BTreeMap<&'static str, &'static str>);
impl AssetReader for Files {
fn read_text(&self, location: &str) -> Option<String> {
self.0.get(location).map(|text| (*text).to_owned())
}
}
fn library(models: &[(&str, &str)], files: &[(&'static str, &'static str)]) -> ModelLibrary {
let models = models
.iter()
.map(|(name, json)| ((*name).to_owned(), CuboidModel::from_json(json).unwrap()))
.collect();
ModelLibrary::new(models)
.unwrap()
.with_assets(Arc::new(Files(files.iter().copied().collect())))
}
fn bake(library: &ModelLibrary, name: &str) -> Vec<ModelQuad> {
bake_block_model(
&library.resolve(name).unwrap(),
BlockModelTransform::new([0; 3], false),
)
.unwrap()
}
fn sprites(quads: &[ModelQuad]) -> Vec<&str> {
quads.iter().map(|q| q.material.sprite.as_str()).collect()
}
const CUBE: &str =
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"up":{"texture":"#all"}}}]}"##;
const COMPOSITE: &str = r#"{"loader":"neoforge:composite","textures":{"all":"block/shared"},
"children":{"zeta":{"parent":"t:cube","textures":{"all":"block/own"}},"alpha":{"parent":"t:cube"}}}"#;
#[test]
fn composite_bakes_children_in_document_order_with_slot_fallback() {
let library = library(&[("t:cube", CUBE), ("t:comp", COMPOSITE)], &[]);
let quads = bake(&library, "t:comp");
assert_eq!(
sprites(&quads),
["minecraft:block/own", "minecraft:block/shared"]
);
}
#[test]
fn composite_visibility_hides_a_child() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let library = library(&[("t:cube", CUBE), ("t:comp", &hidden)], &[]);
assert_eq!(
sprites(&bake(&library, "t:comp")),
["minecraft:block/shared"]
);
}
#[test]
fn an_inheriting_model_overrides_which_children_are_visible() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let shown = r#"{"parent":"t:hidden","visibility":{"zeta":true,"alpha":false}}"#;
let library = library(
&[("t:cube", CUBE), ("t:hidden", &hidden), ("t:shown", shown)],
&[],
);
assert_eq!(sprites(&bake(&library, "t:shown")), ["minecraft:block/own"]);
}
#[test]
fn loader_is_inherited_by_parent_after_own_elements() {
let derived = r##"{"parent":"t:comp","textures":{"all":"block/derived"},
"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"down":{"texture":"#all"}}}]}"##;
let library = library(
&[
("t:cube", CUBE),
("t:comp", COMPOSITE),
("t:derived", derived),
],
&[],
);
let quads = bake(&library, "t:derived");
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(
sprites(&quads),
[
"minecraft:block/derived",
"minecraft:block/own",
"minecraft:block/derived"
]
);
}
#[test]
fn own_loader_wins_and_unknown_loader_adds_nothing() {
let other = r#"{"parent":"t:comp","loader":"somemod:thing"}"#;
let library = library(
&[("t:cube", CUBE), ("t:comp", COMPOSITE), ("t:other", other)],
&[],
);
assert!(bake(&library, "t:other").is_empty());
}
#[test]
fn composites_that_nest_through_themselves_are_an_error() {
let looping = r#"{"loader":"neoforge:composite","children":{"a":{"parent":"t:loop"}}}"#;
let library = library(&[("t:loop", looping)], &[]);
assert!(library.resolve("t:loop").is_err());
}
const OBJ: &str = "mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\nv 0 1 0\n\
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl top\nf 1/1 4/4 3/3 2/2\nusemtl side\nf 1/1 2/2 5/4\nusemtl bare\nf 1/1 2/2 5/4\n";
const MTL: &str = "newmtl top\nmap_Kd #lid\nnewmtl side\nmap_Kd #wall\nnewmtl bare\nKd 1 1 1\n";
const OBJ_FILES: [(&str, &str); 2] = [("t:models/x.obj", OBJ), ("t:models/m.mtl", MTL)];
const OBJ_MODEL: &str = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","flip_v":true,
"textures":{"lid":"block/lid","wall":"block/wall"}}"#;
#[test]
fn obj_faces_become_quads_with_materials_and_flipped_v() {
let flat = OBJ_MODEL.replace(r#""flip_v":true,"#, "");
let library = library(&[("t:o", OBJ_MODEL), ("t:flat", &flat)], &OBJ_FILES);
let quads = bake(&library, "t:o");
assert_eq!(
sprites(&quads),
[
"minecraft:block/lid",
"minecraft:block/wall",
"minecraft:missingno"
]
);
assert_eq!(quads[0].facing, Direction::Up);
assert_eq!(quads[1].facing, Direction::South);
assert_eq!(quads[0].positions[1], [0.0, 0.0, 1.0]);
assert_eq!(quads[0].uv[0], [0.0, 1.0]);
assert_eq!(quads[0].uv[2], [1.0, 0.0]);
// A triangle repeats its last vertex.
assert_eq!(quads[1].positions[3], quads[1].positions[2]);
assert_eq!(quads[1].uv[3], quads[1].uv[2]);
assert!(quads.iter().all(|q| q.cull.is_none() && q.shade));
assert_eq!(bake(&library, "t:flat")[0].uv[0], [0.0, 0.0]);
}
#[test]
fn obj_shade_quads_false_and_mtl_override() {
let unshaded = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","shade_quads":false,
"mtl_override":"t:models/o.mtl","textures":{"s":"block/s"}}"#;
let files = [
("t:models/x.obj", OBJ),
("t:models/o.mtl", "newmtl top\nmap_Kd #s\n"),
];
let library = library(&[("t:u", unshaded)], &files);
let quads = bake(&library, "t:u");
assert!(quads.iter().all(|q| !q.shade));
assert_eq!(quads[0].material.sprite, "minecraft:block/s");
}
#[test]
fn missing_or_broken_obj_bakes_nothing() {
let missing = library(&[("t:o", OBJ_MODEL)], &[]);
assert!(bake(&missing, "t:o").is_empty());
let broken = library(
&[("t:o", OBJ_MODEL)],
&[("t:models/x.obj", "v 0 0 0\nf 1 2 3\n")],
);
assert!(bake(&broken, "t:o").is_empty());
}
#[test]
fn obj_visibility_skips_the_objects_a_model_hides() {
let two = "v 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\no a\nusemtl m\nf 1 2 3 4\no b\nf 4 3 2 1\n";
let files = [("t:models/two.obj", two)];
let base =
r#"{"loader":"neoforge:obj","model":"t:models/two.obj","textures":{"m":"block/m"}}"#;
let hiding = r#"{"parent":"t:base","visibility":{"b":false}}"#;
let restoring = r#"{"parent":"t:hiding","visibility":{"b":true}}"#;
let library = library(
&[
("t:base", base),
("t:hiding", hiding),
("t:restoring", restoring),
],
&files,
);
assert_eq!(bake(&library, "t:base").len(), 2);
let quads = bake(&library, "t:hiding");
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(bake(&library, "t:restoring").len(), 2);
}
}
+54
View File
@@ -17,6 +17,7 @@ use std::{
};
mod component_predicate;
mod foreign;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -431,6 +432,17 @@ impl ItemModelNode {
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let kind = ident(required(m, "type")?)?;
if kind == "minecraft:condition" {
let property = ident(required(m, "property")?)?;
if !property.starts_with("minecraft:") {
return Ok(Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::Custom(property),
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
});
}
}
Ok(match kind.as_str() {
"minecraft:empty" => Self::Empty,
"minecraft:model" => Self::Model(ModelLeaf {
@@ -451,12 +463,21 @@ impl ItemModelNode {
.collect::<Result<_>>()?,
transformation: optional_transformation(m)?,
},
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
Self::decode(required(m, "on_false")?)?,
optional_transformation(m)?,
),
"minecraft:condition" => Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::decode(m)?,
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
},
"minecraft:select" if foreign::property_is_foreign(m)? => {
// No case can match a property Horizon cannot read.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
}
"minecraft:select" => {
let transformation = optional_transformation(m)?;
let property = SelectProperty::decode(m)?;
@@ -484,6 +505,7 @@ impl ItemModelNode {
}
}
"minecraft:range_dispatch" => {
let foreign_property = foreign::property_is_foreign(m)?;
let mut entries = list(required(m, "entries")?)?
.iter()
.map(|e| {
@@ -496,6 +518,17 @@ impl ItemModelNode {
.collect::<Result<Vec<_>>>()?;
// `Comparator.comparingDouble`: Double.compare, a total order.
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
if foreign_property {
// A property Horizon cannot read is 0.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
let chosen = last_index_less_or_equal(&entries, 0.0)
.map(|i| entries.swap_remove(i).1)
.or(fallback);
return Ok(foreign::wrap(
chosen.unwrap_or(Self::Empty),
optional_transformation(m)?,
));
}
Self::RangeDispatch {
transformation: optional_transformation(m)?,
property: NumericProperty::decode(m)?,
@@ -506,12 +539,21 @@ impl ItemModelNode {
.transpose()?,
}
}
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
// The mod's own renderer is not run: the base model stands in.
Self::Model(ModelLeaf {
model: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
tints: Vec::new(),
})
}
"minecraft:special" => Self::Special {
base: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
model: SpecialModel::decode(required(m, "model")?)?,
},
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
_ => return Err(err("unknown item model type")),
})
}
@@ -603,6 +645,8 @@ impl TintSource {
"minecraft:firework" => Self::Firework(default()?),
"minecraft:potion" => Self::Potion(default()?),
"minecraft:team" => Self::Team(default()?),
// A mod's own tint source, which its Java code computes: untinted here.
other if foreign::is_foreign(other) => Self::Constant(-1),
_ => return Err(err("unknown item tint source")),
})
}
@@ -614,6 +658,7 @@ impl TintSource {
#[derive(Clone, Debug, PartialEq)]
pub enum ConditionProperty {
Custom(String),
CustomModelData(i32),
UsingItem,
Broken,
@@ -946,6 +991,10 @@ pub struct Context<'a> {
/// Host services the resolver cannot derive from data alone.
pub trait ItemEnv {
/// A mod's namespaced conditional property, false when no mod supplies it.
fn item_model_property(&self, _property: &str, _stack: &StackView) -> bool {
false
}
/// `PotionContents.getColorOr` for the potion/effects part: the mixed
/// effect colour, or `None` when the contents have no visible effects.
fn potion_effect_color(&self, _potion_contents: &Value) -> Option<i32> {
@@ -1299,6 +1348,9 @@ impl Resolve<'_> {
fn condition(&self, property: &ConditionProperty) -> bool {
let owner = self.ctx.owner;
match property {
ConditionProperty::Custom(property) => {
self.env.item_model_property(property, self.stack)
}
ConditionProperty::CustomModelData(i) => {
self.component("minecraft:custom_model_data")
.and_then(|d| custom_data_at(d, "flags", *i))
@@ -1717,5 +1769,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
Err(low as usize)
}
#[cfg(test)]
mod foreign_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,114 @@
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
use super::{
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
optional_transformation,
};
use crate::{json::error as err, math::Mat4};
use serde_json::{Map, Value};
/// Keys a mod's node usually keeps its real model under, in order of preference.
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
pub fn is_foreign(id: &str) -> bool {
!id.starts_with("minecraft:")
}
/// A composite carries the transformation a collapsed node had.
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
match transformation {
None => node,
Some(_) => ItemModelNode::Composite {
models: vec![node],
transformation,
},
}
}
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
let property = m
.get("property")
.ok_or_else(|| err("missing property"))
.and_then(ident)?;
Ok(is_foreign(&property))
}
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
let tints = get(m, "tints")
.and_then(|t| list(t).ok())
.map(|t| {
t.iter()
.filter_map(|t| TintSource::decode(t).ok())
.collect()
})
.unwrap_or_default();
ItemModelNode::Model(ModelLeaf {
model,
transformation: optional_transformation(m).ok().flatten(),
tints,
})
}
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
if kind == "neoforge:fluid_container" {
return fluid_container(m);
}
for key in MODEL_KEYS {
match m.get(key) {
Some(nested @ Value::Object(_)) => {
if let Ok(node) = ItemModelNode::decode(nested) {
return wrap(node, optional_transformation(m).ok().flatten());
}
}
Some(Value::String(id)) => {
if let Ok(id) = identifier(id) {
return leaf(id, m);
}
}
_ => {}
}
}
let by_suffix = m
.iter()
.filter(|(k, _)| k.ends_with("_model"))
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
match by_suffix {
Some(id) => leaf(id, m),
None => ItemModelNode::Empty,
}
}
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
let textures = m.get("textures").and_then(|t| object(t).ok());
let texture = |key: &str| {
textures
.and_then(|t| t.get(key))
.and_then(Value::as_str)
.and_then(|id| identifier(id).ok())
};
let mut layers = vec![texture("base")];
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
layers.push(texture("cover"));
}
let mut models: Vec<ItemModelNode> = layers
.into_iter()
.flatten()
.map(|model| {
ItemModelNode::Model(ModelLeaf {
model,
transformation: None,
tints: Vec::new(),
})
})
.collect();
let transformation = optional_transformation(m).ok().flatten();
match models.len() {
0 => ItemModelNode::Empty,
1 => wrap(models.remove(0), transformation),
_ => ItemModelNode::Composite {
models,
transformation,
},
}
}
@@ -0,0 +1,185 @@
use super::*;
fn node(json: &str) -> Result<ItemModelNode> {
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
}
fn model_id(n: &ItemModelNode) -> &str {
match n {
ItemModelNode::Model(leaf) => &leaf.model,
other => panic!("not a model: {other:?}"),
}
}
#[test]
fn node_with_a_model_id_draws_that_model() {
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
.unwrap();
assert_eq!(model_id(&n), "mod:item/thing");
let ItemModelNode::Model(leaf) = n else {
unreachable!()
};
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/base");
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/a");
}
#[test]
fn node_with_a_nested_node_decodes_it() {
let n = node(
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/boots");
let n = node(
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
)
.unwrap();
let ItemModelNode::Composite {
models,
transformation,
} = n
else {
panic!("a transformation wraps the nested node")
};
assert!(transformation.is_some());
assert_eq!(model_id(&models[0]), "mod:item/inner");
}
#[test]
fn node_with_nothing_usable_is_empty() {
assert!(matches!(
node(r#"{"type":"mod:facade"}"#).unwrap(),
ItemModelNode::Empty
));
assert!(matches!(
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
ItemModelNode::Empty
));
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
}
#[test]
fn unknown_vanilla_names_still_fail() {
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
for json in [
format!(
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
),
] {
assert!(node(&json).is_err(), "{json}");
}
assert!(
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
.is_err()
);
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
}
#[test]
fn condition_property_is_false() {
let n = node(
r#"{"type":"minecraft:condition","property":"mod:flag",
"on_true":{"type":"minecraft:model","model":"a:yes"},
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
// A mod's property stays a condition its mod answers per stack
// (`item_model_property`); with no mod answering it, `on_false` is drawn.
let ItemModelNode::Condition {
property: ConditionProperty::Custom(property),
on_false,
..
} = &n
else {
panic!("not a custom condition: {n:?}")
};
assert_eq!(property, "mod:flag");
assert_eq!(model_id(on_false), "a:no");
}
#[test]
fn select_property_takes_the_fallback_or_nothing() {
let n = node(
r#"{"type":"minecraft:select","property":"mod:level",
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:other");
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn range_property_reads_zero() {
let entry = |t: f32, m: &str| {
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
};
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
let make = |entries: &[String], fallback: &str| {
node(&format!(
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
entries.join(",")
))
.unwrap()
};
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
assert_eq!(model_id(&n), "a:fb");
let n = make(
&[
entry(0.0, "a:zero"),
entry(-1.0, "a:neg"),
entry(0.5, "a:half"),
],
fallback,
);
assert_eq!(model_id(&n), "a:zero");
let n = make(&[entry(0.2, "a:one")], "");
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn special_type_keeps_its_base_model() {
let n = node(
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/cube");
let n =
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
.unwrap();
assert!(matches!(n, ItemModelNode::Special { .. }));
}
#[test]
fn fluid_container_draws_its_base_without_the_fluid() {
let n = node(
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "minecraft:item/bucket");
let n = node(
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
)
.unwrap();
let ItemModelNode::Composite { models, .. } = n else {
panic!("base and cover")
};
assert_eq!(models.len(), 2);
assert!(matches!(
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
ItemModelNode::Empty
));
}
@@ -77,6 +77,13 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
}
impl SpecialModel {
/// A special model type of another namespace, which Horizon has no renderer for.
pub fn is_foreign(v: &Value) -> bool {
v.get("type")
.and_then(Value::as_str)
.and_then(|t| super::identifier(t).ok())
.is_some_and(|t| super::foreign::is_foreign(&t))
}
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
@@ -18,3 +18,96 @@ fn display_context_names_round_trip() {
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
}
}
#[test]
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
assert_eq!(
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
TintSource::Constant(-1)
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
#[test]
fn custom_condition_is_evaluated_per_stack_at_branch_selection() {
struct Env;
impl ItemEnv for Env {
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
assert_eq!(property, "example:charged");
stack.count > 1 && stack.patch.get("example:charged") == Some(&Some(Value::Bool(true)))
}
}
let definition = ClientItem::from_json(
r#"{"model":{
"type":"minecraft:condition", "property":"example:charged",
"on_true":{"type":"minecraft:model","model":"example:item/charged"},
"on_false":{"type":"minecraft:model","model":"example:item/idle"}
}}"#,
)
.unwrap();
let mut models = ItemModels::default();
models.definitions.insert("example:tool".into(), definition);
models.prototypes.insert(
"example:tool".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
Value::String("example:tool".into()),
)]),
);
let context = Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
};
let mut stack = StackView::new("example:tool", 1);
let chosen = |stack: &StackView, env: &dyn ItemEnv| {
let resolved = models.resolve(stack, &context, env);
assert_eq!(resolved.layers.len(), 1);
let LayerContent::Model { model } = &resolved.layers[0].content else {
panic!("expected model leaf")
};
model.clone()
};
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack
.patch
.insert("example:charged".into(), Some(Value::Bool(true)));
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack.count = 2;
assert_eq!(chosen(&stack, &Env), "example:item/charged");
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
stack.patch.insert("example:charged".into(), None);
assert_eq!(chosen(&stack, &Env), "example:item/idle");
}
#[test]
fn custom_condition_validates_its_name_and_both_branches() {
let node = |property: &str, on_true: Value| {
ItemModelNode::decode(&serde_json::json!({
"type": "minecraft:condition", "property": property,
"on_true": on_true, "on_false": {"type": "minecraft:empty"}
}))
};
let empty = serde_json::json!({"type": "minecraft:empty"});
for name in [
"minecraft:unknown",
"unknown",
"example:Bad",
"bad name:charged",
] {
assert!(node(name, empty.clone()).is_err(), "{name}");
}
assert!(node("example:charged", Value::Null).is_err());
assert!(matches!(
node("minecraft:damaged", empty.clone()).unwrap(),
ItemModelNode::Condition {
property: ConditionProperty::Damaged,
..
}
));
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
+33
View File
@@ -87,6 +87,39 @@ pub(crate) fn object(raw: &RawValue) -> Result<Object<'_>> {
}
Ok(serde_json::from_str(raw.get())?)
}
/// The members of an object in document order, which `object` loses.
pub(crate) fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
struct Members<'a>(Vec<(String, &'a RawValue)>);
impl<'de> serde::Deserialize<'de> for Members<'de> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Members<'de>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut members: Vec<(String, &'de RawValue)> = Vec::new();
while let Some(entry) = map.next_entry::<String, &'de RawValue>()? {
members.retain(|(name, _)| *name != entry.0);
members.push(entry);
}
Ok(Members(members))
}
}
deserializer.deserialize_map(Visitor)
}
}
if !raw.get().starts_with('{') {
return Err(error("expected object"));
}
Ok(serde_json::from_str::<Members>(raw.get())?.0)
}
pub(crate) fn array(raw: &RawValue) -> Result<Vec<&RawValue>> {
Ok(serde_json::from_str(raw.get())?)
}
+1
View File
@@ -9,6 +9,7 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod obj;
pub mod palette;
pub mod resolve;
+172 -1
View File
@@ -5,7 +5,7 @@
//! does; render preparation must diagnose these before baking.
use crate::json::*;
use serde_json::value::RawValue;
use std::collections::BTreeMap;
use std::{collections::BTreeMap, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
@@ -59,11 +59,92 @@ pub enum TextureSlot {
#[derive(Clone, Debug, PartialEq)]
pub struct CuboidModel {
pub parent: Option<String>,
pub loader: Option<Loader>,
pub ambient_occlusion: Option<bool>,
pub gui_light: Option<GuiLight>,
pub textures: BTreeMap<String, TextureSlot>,
pub elements: Option<Vec<Option<Element>>>,
pub display: Option<ItemTransforms>,
/// NeoForge's `render_type`, without a `minecraft:` prefix.
pub render_type: Option<String>,
/// A model's `visibility`: an obj object or composite child name to shown; absent is shown.
pub visibility: BTreeMap<String, bool>,
}
/// A custom model loader's payload; a model inherits its parent's loader unless it names its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Loader {
/// `neoforge:composite`: children in document order.
Composite {
children: Vec<(String, Arc<CuboidModel>)>,
/// Child name to shown; a child absent from the map is shown.
visibility: BTreeMap<String, bool>,
},
/// `neoforge:obj`: `model` and `mtl_override` are asset locations.
Obj {
model: String,
flip_v: bool,
shade_quads: Option<bool>,
mtl_override: Option<String>,
},
/// A loader this decoder does not know: it adds no geometry.
Unknown(String),
}
/// NeoForge's `neoforge_data` of an element or a face; a face's keys win.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NeoforgeData {
pub block_light: Option<u8>,
pub sky_light: Option<u8>,
/// ARGB.
pub color: Option<i32>,
pub ambient_occlusion: Option<bool>,
}
impl NeoforgeData {
/// `self` over `base`, key by key.
pub fn over(self, base: Self) -> Self {
Self {
block_light: self.block_light.or(base.block_light),
sky_light: self.sky_light.or(base.sky_light),
color: self.color.or(base.color),
ambient_occlusion: self.ambient_occlusion.or(base.ambient_occlusion),
}
}
fn decode(raw: &RawValue) -> Result<Self> {
let obj = object(raw)?;
let light = |key: &str| -> Result<Option<u8>> {
Ok(obj
.get(key)
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8))
};
// Some mods spell both lights as one `light_emission`.
let emission = light("light_emission")?;
let color = match obj.get("color") {
Some(raw) if raw.get().starts_with('"') => {
let text = string(raw)?;
let text = text.trim();
let hex = text
.strip_prefix('#')
.or_else(|| text.strip_prefix("0x"))
.or_else(|| text.strip_prefix("0X"));
match hex {
Some(hex) => u32::from_str_radix(hex, 16).ok().map(|v| v as i32),
None => text.parse::<u32>().ok().map(|v| v as i32),
}
}
Some(raw) => Some(integer(raw)?),
None => None,
};
Ok(Self {
block_light: light("block_light")?.or(emission),
sky_light: light("sky_light")?.or(emission),
color,
ambient_occlusion: obj
.get("ambient_occlusion")
.map(|r| boolean(r))
.transpose()?,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
@@ -73,6 +154,7 @@ pub struct Element {
pub rotation: Option<ElementRotation>,
pub shade_direction_override: Option<Direction>,
pub light_emission: u8,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Face {
@@ -82,6 +164,9 @@ pub struct Face {
pub uv: Option<[f32; 4]>,
/// Normalized counterclockwise quadrants, in the range 0..4.
pub rotation: u8,
/// A face-level `light_emission`, which wins over its element's.
pub light_emission: Option<u8>,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
@@ -154,6 +239,11 @@ impl CuboidModel {
} else {
Some(identifier(&parent)?)
};
let loader = obj
.get("loader")
.filter(|raw| raw.get() != "null")
.map(|raw| Loader::decode(raw, &obj))
.transpose()?;
let ambient_occlusion = obj
.get("ambientocclusion")
.map(|r| boolean(r))
@@ -185,15 +275,83 @@ impl CuboidModel {
.get("display")
.map(|r| ItemTransforms::decode(r))
.transpose()?;
let render_type = obj
.get("render_type")
.filter(|raw| raw.get().starts_with('"'))
.map(|raw| string(raw))
.transpose()?
.map(|name| name.strip_prefix("minecraft:").unwrap_or(&name).to_owned());
Ok(Self {
parent,
loader,
ambient_occlusion,
gui_light,
textures,
elements,
display,
render_type,
visibility: visibility(&obj)?,
})
}
/// The models this one needs besides itself: its parent and its composite children's parents.
pub fn referenced_models(&self) -> Vec<String> {
let mut names: Vec<String> = self.parent.iter().cloned().collect();
if let Some(Loader::Composite { children, .. }) = &self.loader {
for (_, child) in children {
names.extend(child.referenced_models());
}
}
names
}
}
fn visibility(obj: &Object<'_>) -> Result<BTreeMap<String, bool>> {
let mut visibility = BTreeMap::new();
if let Some(raw) = obj.get("visibility") {
for (key, raw) in object(raw)? {
visibility.insert(key, boolean(raw)?);
}
}
Ok(visibility)
}
impl Loader {
fn decode(raw: &RawValue, obj: &Object<'_>) -> Result<Self> {
let name = primitive_string(raw)?;
match name.as_str() {
"neoforge:composite" => {
let mut children = Vec::new();
if let Some(raw) = obj.get("children") {
for (name, raw) in ordered_object(raw)? {
children.push((name, Arc::new(CuboidModel::decode(raw)?)));
}
}
Ok(Self::Composite {
children,
visibility: visibility(obj)?,
})
}
"neoforge:obj" => {
let optional = |key: &str| {
obj.get(key)
.filter(|raw| raw.get() != "null")
.map(|raw| primitive_string(raw))
.transpose()
};
Ok(Self::Obj {
model: identifier(&optional("model")?.ok_or_else(|| error("missing model"))?)?,
flip_v: obj
.get("flip_v")
.map(|r| boolean(r))
.transpose()?
.unwrap_or(false),
shade_quads: obj.get("shade_quads").map(|r| boolean(r)).transpose()?,
mtl_override: optional("mtl_override")?
.map(|name| identifier(&name))
.transpose()?,
})
}
_ => Ok(Self::Unknown(name)),
}
}
}
fn nullable<T>(raw: &RawValue, decode: impl FnOnce(&RawValue) -> Result<T>) -> Result<Option<T>> {
if raw.get() == "null" {
@@ -273,6 +431,10 @@ impl Element {
rotation,
shade_direction_override,
light_emission: light_emission as u8,
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
@@ -306,6 +468,15 @@ impl Face {
texture,
uv,
rotation: (rotation / 90) as u8,
light_emission: obj
.get("light_emission")
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8),
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Wavefront OBJ and MTL text, as obj-loader models use them.
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ObjVertex {
/// Block units.
pub position: [f32; 3],
/// Texture coordinates as written, 0..1 across the sprite.
pub uv: [f32; 2],
}
/// One quad; a triangle repeats its last vertex.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjFace {
/// Empty before any `usemtl`.
pub material: String,
/// The `o` (else `g`) it belongs to, which a model's `visibility` names.
pub object: String,
pub vertices: [ObjVertex; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParsedObj {
pub mtllib: Option<String>,
pub faces: Vec<ObjFace>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MtlTexture {
/// `map_Kd #name`: a texture slot of the model.
Slot(String),
/// `map_Kd namespace:path`: a sprite.
Sprite(String),
}
/// A parsed OBJ with its materials.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjModel {
pub faces: Vec<ObjFace>,
pub materials: BTreeMap<String, MtlTexture>,
}
pub fn parse_obj(text: &str) -> Result<ParsedObj, String> {
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut parsed = ParsedObj::default();
let mut material = String::new();
let mut object = String::new();
for (number, line) in text.lines().enumerate() {
let mut words = line.split_whitespace();
let Some(keyword) = words.next() else {
continue;
};
let at = |message: &str| format!("line {}: {message}", number + 1);
match keyword {
_ if keyword.starts_with('#') => {}
"v" => positions.push(floats::<3>(words).ok_or_else(|| at("bad vertex"))?),
"vt" => {
let mut words = words;
let u = words.next().and_then(|w| w.parse().ok());
let v = words.next().map_or(Some(0.0), |w| w.parse().ok());
let (Some(u), Some(v)) = (u, v) else {
return Err(at("bad texture coordinate"));
};
uvs.push([u, v]);
}
"mtllib" => parsed.mtllib = words.next().map(str::to_owned),
"usemtl" => material = words.next().unwrap_or("").to_owned(),
"o" | "g" => object = words.next().unwrap_or("").to_owned(),
"f" => {
let mut corners = Vec::new();
for word in words {
let mut parts = word.split('/');
let position = index(parts.next(), positions.len())
.ok_or_else(|| at("bad vertex index"))?;
let uv = match parts.next() {
None | Some("") => [0.0; 2],
some => {
uvs[index(some, uvs.len()).ok_or_else(|| at("bad texture index"))?]
}
};
corners.push(ObjVertex {
position: positions[position],
uv,
});
}
if corners.len() < 3 {
return Err(at("face needs three vertices"));
}
let mut push = |vertices: [ObjVertex; 4]| {
parsed.faces.push(ObjFace {
material: material.clone(),
object: object.clone(),
vertices,
});
};
if corners.len() <= 4 {
let last = corners[corners.len() - 1];
push(std::array::from_fn(|i| {
corners.get(i).copied().unwrap_or(last)
}));
} else {
for i in 1..corners.len() - 1 {
push([corners[0], corners[i], corners[i + 1], corners[i + 1]]);
}
}
}
_ => {}
}
}
Ok(parsed)
}
fn floats<'a, const N: usize>(mut words: impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
let mut result = [0.0; N];
for value in &mut result {
*value = words.next()?.parse().ok()?;
}
Some(result)
}
/// A 1-based index, or a negative one counted from the end.
fn index(word: Option<&str>, len: usize) -> Option<usize> {
let value: i64 = word?.parse().ok()?;
let index = if value < 0 {
len as i64 + value
} else {
value - 1
};
(0..len as i64).contains(&index).then_some(index as usize)
}
/// Material name to its `map_Kd`; other statements are ignored.
pub fn parse_mtl(text: &str) -> BTreeMap<String, MtlTexture> {
let mut materials = BTreeMap::new();
let mut current = None;
for line in text.lines() {
let mut words = line.split_whitespace();
match words.next() {
Some("newmtl") => current = words.next().map(str::to_owned),
Some("map_Kd") => {
let (Some(name), Some(value)) = (&current, words.last()) else {
continue;
};
let texture = match value.strip_prefix('#') {
Some(slot) => MtlTexture::Slot(slot.to_owned()),
None => MtlTexture::Sprite(value.to_owned()),
};
materials.insert(name.clone(), texture);
}
_ => {}
}
}
materials
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quads_triangles_and_negative_indices() {
let text = "mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl first\nf 1/1/1 2/2/1 3/3/1 4/4/1\nusemtl second\nf -4//1 -3//1 -2//1\n";
let parsed = parse_obj(text).unwrap();
assert_eq!(parsed.mtllib.as_deref(), Some("a.mtl"));
assert_eq!(parsed.faces.len(), 2);
assert_eq!(parsed.faces[0].material, "first");
assert_eq!(parsed.faces[0].vertices[2].uv, [1.0, 1.0]);
let triangle = &parsed.faces[1];
assert_eq!(triangle.material, "second");
assert_eq!(triangle.vertices[0].position, [0.0, 0.0, 0.0]);
assert_eq!(triangle.vertices[3], triangle.vertices[2]);
assert_eq!(triangle.vertices[2].position, [1.0, 1.0, 0.0]);
}
#[test]
fn bad_indices_are_errors() {
assert!(parse_obj("v 0 0 0\nf 1 2 3\n").is_err());
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
}
#[test]
fn mtl_maps_slots_and_sprites() {
let materials =
parse_mtl("newmtl a\nKd 1 1 1\nmap_Kd #top\nnewmtl b\nmap_Kd mod:block/x\nnewmtl c\n");
assert_eq!(materials["a"], MtlTexture::Slot("top".into()));
assert_eq!(materials["b"], MtlTexture::Sprite("mod:block/x".into()));
assert!(!materials.contains_key("c"));
}
}
+319 -22
View File
@@ -1,18 +1,45 @@
//! Cuboid parent inheritance and texture-slot resolution.
use crate::{json::*, model::*};
use crate::{
json::*,
model::*,
obj::{ObjModel, parse_mtl, parse_obj},
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
sync::{Arc, Mutex},
};
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
/// `ItemModelGenerator.GENERATED_ITEM_MODEL_ID`.
pub const GENERATED_MODEL: &str = "minecraft:builtin/generated";
/// Composite children nest through parents that may name the composite again.
const MAX_LOADER_DEPTH: u8 = 8;
/// Reads asset files that models point at (`namespace:path` is `assets/namespace/path`).
pub trait AssetReader: Send + Sync {
fn read_text(&self, location: &str) -> Option<String>;
}
/// Decoded models are immutable and their inherited geometry is shared.
pub struct ModelLibrary {
models: BTreeMap<String, Arc<CuboidModel>>,
missing: Arc<CuboidModel>,
assets: Option<Arc<dyn AssetReader>>,
/// Keyed by obj and mtl override; `None` is a file that failed, already warned about.
objs: Mutex<BTreeMap<(String, Option<String>), Option<Arc<ObjModel>>>>,
}
/// What a model's loader adds to its own elements.
#[derive(Clone, Debug)]
pub enum ResolvedLoader {
/// The shown children, resolved on their own, in order.
Composite(Vec<ResolvedCuboidModel>),
Obj {
/// `None` when the file failed to load.
model: Option<Arc<ObjModel>>,
flip_v: bool,
shade: bool,
/// Objects the model chain's `visibility` hides.
hidden: BTreeSet<String>,
},
}
#[derive(Clone, Debug)]
pub struct ResolvedCuboidModel {
@@ -22,10 +49,17 @@ pub struct ResolvedCuboidModel {
pub textures: BTreeMap<String, Material>,
/// Cyclic and dangling aliases are absent from `textures`.
pub unresolved_textures: BTreeSet<String>,
/// NeoForge's `render_type`; the leaf-most model that sets one wins.
pub render_type: Option<String>,
geometry: Option<Arc<CuboidModel>>,
generated: bool,
loader: Option<ResolvedLoader>,
}
impl ResolvedCuboidModel {
/// Geometry from a custom loader, baked after the elements.
pub fn loader(&self) -> Option<&ResolvedLoader> {
self.loader.as_ref()
}
/// Whether the top geometry is `builtin/generated`'s layer extrusion
/// (no model closer to the leaf has elements of its own).
pub fn uses_item_generator(&self) -> bool {
@@ -66,6 +100,7 @@ impl ModelLibrary {
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
@@ -74,6 +109,8 @@ impl ModelLibrary {
)]),
elements: None,
display: None,
render_type: None,
visibility: BTreeMap::new(),
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
@@ -100,8 +137,15 @@ impl ModelLibrary {
Ok(Self {
models: decoded,
missing,
assets: None,
objs: Mutex::default(),
})
}
/// Where `neoforge:obj` models find their files.
pub fn with_assets(mut self, assets: Arc<dyn AssetReader>) -> Self {
self.assets = Some(assets);
self
}
/// This library plus `extra` models; an `extra` model replaces a
/// same-named one, but not the built-in missing and generated models.
/// Every existing model is shared, not copied.
@@ -122,13 +166,35 @@ impl ModelLibrary {
Ok(Self {
models,
missing: Arc::clone(&self.missing),
assets: self.assets.clone(),
objs: Mutex::default(),
})
}
/// Follow the complete parent chain. A missing resource uses the built-in
/// model; a cyclic chain is rejected, as native discovery excludes it.
/// The returned model can outlive this library without copying its elements.
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
self.resolve_name(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
pub fn resolves(&self, name: &str) -> bool {
let Ok(mut name) = identifier(name) else {
return false;
};
let mut visited = BTreeSet::new();
loop {
let Some(model) = self.models.get(&name) else {
return false;
};
if !visited.insert(name.clone()) {
return false;
}
match &model.parent {
Some(parent) => name = parent.clone(),
None => return true,
}
}
}
/// `name`'s resolved model with a virtual child in front of it that has
/// only `textures`: the child's slots win, and `#references` anywhere in
@@ -143,28 +209,40 @@ impl ModelLibrary {
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
self.resolve_name(name, Some(child))
}
fn resolve_chain(
fn resolve_name(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let next = Some(identifier(name)?);
self.resolve_chain(Vec::new(), Vec::new(), next, &BTreeMap::new(), child, 0)
}
/// `chain` and `names` start with models that are not in the library (a
/// composite child); `next` is the parent to follow. `fallback` slots lose
/// to any slot the chain defines.
fn resolve_chain(
&self,
mut chain: Vec<Arc<CuboidModel>>,
mut names: Vec<String>,
mut next: Option<String>,
fallback: &BTreeMap<String, TextureSlot>,
child: Option<&BTreeMap<String, TextureSlot>>,
depth: u8,
) -> Result<ResolvedCuboidModel> {
if depth > MAX_LOADER_DEPTH {
return Err(error("composite models nest too deeply"));
}
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
let mut names = Vec::new();
loop {
while let Some(name) = next.take() {
if !visited.insert(name.clone()) {
return Err(error("cyclic model parent"));
}
let model = self.models.get(&name).unwrap_or(&self.missing);
chain.push(model);
names.push(name.clone());
match &model.parent {
Some(parent) => name = parent.clone(),
None => break,
}
chain.push(Arc::clone(model));
names.push(name);
next = model.parent.clone();
}
let ambient_occlusion = chain
.iter()
@@ -174,10 +252,8 @@ impl ModelLibrary {
.iter()
.find_map(|m| m.gui_light)
.unwrap_or(GuiLight::Side);
let geometry = chain
.iter()
.find(|m| m.elements.is_some())
.map(|m| Arc::clone(m));
let render_type = chain.iter().find_map(|m| m.render_type.clone());
let geometry = chain.iter().find(|m| m.elements.is_some()).map(Arc::clone);
let generated = chain
.iter()
.zip(&names)
@@ -196,13 +272,68 @@ impl ModelLibrary {
};
// Resolve aliases only after applying every child override. A reference
// inherited from a parent binds to the child's final slot definition.
let mut slots = BTreeMap::new();
for model in chain.into_iter().rev() {
let mut slots: BTreeMap<String, &TextureSlot> =
fallback.iter().map(|(k, v)| (k.clone(), v)).collect();
for model in chain.iter().rev() {
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
}
if let Some(child) = child {
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
}
let loader = match chain.iter().find_map(|m| m.loader.as_ref()) {
Some(Loader::Composite {
children,
visibility,
}) => {
let inherited: BTreeMap<String, TextureSlot> = slots
.iter()
.map(|(k, v)| (k.clone(), (*v).clone()))
.collect();
// A model closer to the leaf overrides the loader's own map.
let mut visibility = visibility.clone();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
let mut resolved = Vec::new();
for (child_name, child_model) in children {
if visibility.get(child_name) == Some(&false) {
continue;
}
resolved.push(self.resolve_chain(
vec![Arc::clone(child_model)],
vec![format!("{}#{child_name}", names[0])],
child_model.parent.clone(),
&inherited,
None,
depth + 1,
)?);
}
Some(ResolvedLoader::Composite(resolved))
}
Some(Loader::Obj {
model,
flip_v,
shade_quads,
mtl_override,
}) => {
// Leaf wins: a model closer to the leaf overrides its parents' entries.
let mut visibility = BTreeMap::new();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
Some(ResolvedLoader::Obj {
model: self.load_obj(&names[0], model, mtl_override.as_deref()),
flip_v: *flip_v,
shade: shade_quads.unwrap_or(true),
hidden: visibility
.into_iter()
.filter(|(_, shown)| !shown)
.map(|(name, _)| name)
.collect(),
})
}
Some(Loader::Unknown(_)) | None => None,
};
let mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -244,12 +375,61 @@ impl ModelLibrary {
display,
textures,
unresolved_textures,
render_type,
geometry,
generated,
loader,
})
}
/// Parse an obj and its materials once; a failure warns once and yields no geometry.
fn load_obj(&self, label: &str, model: &str, mtl: Option<&str>) -> Option<Arc<ObjModel>> {
let key = (model.to_owned(), mtl.map(str::to_owned));
let mut cache = self.objs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(&key) {
return found.clone();
}
let loaded = self.read_obj(model, mtl);
if let Err(message) = &loaded {
log::warn!("Horizon: obj model {label} ({model}): {message}");
}
let loaded = loaded.ok().map(Arc::new);
cache.insert(key, loaded.clone());
loaded
}
fn read_obj(&self, model: &str, mtl: Option<&str>) -> std::result::Result<ObjModel, String> {
let assets = self.assets.as_ref().ok_or("no asset source")?;
let text = assets.read_text(model).ok_or("file not found")?;
let parsed = parse_obj(&text)?;
let mtl = match (mtl, &parsed.mtllib) {
(Some(location), _) => Some(location.to_owned()),
(None, Some(file)) if file.contains(':') => Some(file.clone()),
(None, Some(file)) => {
let (namespace, path) = model.split_once(':').unwrap_or(("minecraft", model));
let directory = path.rsplit_once('/').map_or("", |(dir, _)| dir);
Some(if directory.is_empty() {
format!("{namespace}:{file}")
} else {
format!("{namespace}:{directory}/{file}")
})
}
(None, None) => None,
};
let materials = match mtl {
Some(location) => match assets.read_text(&location) {
Some(text) => parse_mtl(&text),
None => {
log::warn!("Horizon: obj material file {location} not found");
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
Ok(ObjModel {
faces: parsed.faces,
materials,
})
}
}
/// The native missing-model cube uses slot names without a leading '#'.
pub fn missing_model() -> CuboidModel {
let faces = Direction::ALL
@@ -263,12 +443,15 @@ pub fn missing_model() -> CuboidModel {
texture: "missingno".to_owned(),
uv: Some([0.0, 0.0, 16.0, 16.0]),
rotation: 0,
light_emission: None,
neoforge_data: None,
}),
)
})
.collect();
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: None,
elements: Some(vec![Some(Element {
@@ -278,10 +461,12 @@ pub fn missing_model() -> CuboidModel {
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
})]),
display: Some(ItemTransforms {
contexts: std::array::from_fn(|_| DisplayTransform::Inherit),
}),
render_type: None,
textures: BTreeMap::from([
(
"particle".to_owned(),
@@ -295,6 +480,7 @@ pub fn missing_model() -> CuboidModel {
}),
),
]),
visibility: BTreeMap::new(),
}
}
@@ -529,4 +715,115 @@ mod tests {
"minecraft:missingno"
);
}
#[test]
fn a_model_resolves_only_with_its_whole_parent_chain() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
("item/orphan", r#"{"parent":"item/gone"}"#),
("item/a", r#"{"parent":"item/b"}"#),
("item/b", r#"{"parent":"item/a"}"#),
]);
assert!(library.resolves("item/stick"));
assert!(library.resolves("minecraft:item/stick"));
assert!(!library.resolves("item/absent"));
assert!(!library.resolves("item/orphan"));
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
const LIGHTS: &str = r##"{"textures":{"a":"block/a"},"render_type":"minecraft:cutout","elements":[
{"from":[0,0,0],"to":[16,16,16],"light_emission":4,"neoforge_data":{"block_light":9,"color":-65536,"ambient_occlusion":false},
"faces":{"north":{"texture":"#a","neoforge_data":{"block_light":2,"sky_light":15,"unknown":1}},
"south":{"texture":"#a","light_emission":11},
"up":{"texture":"#a"}}}]}"##;
#[test]
fn neoforge_data_of_a_face_overrides_its_element_key_by_key() {
use crate::bake::bake_block_model;
let library = library(&[("block/lights", LIGHTS)]);
let model = library.resolve("block/lights").unwrap();
let quads =
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false)).unwrap();
let by = |d| quads.iter().find(|q| q.facing == d).unwrap();
let north = by(Direction::North);
assert_eq!(north.light, [4, 15]);
assert_eq!(north.color, Some(-65536));
assert_eq!(north.ambient_occlusion, Some(false));
assert_eq!(by(Direction::South).light, [11, 11]);
assert_eq!(by(Direction::South).light_emission, 11);
assert_eq!(by(Direction::Up).light, [9, 4]);
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Cutout))
);
}
#[test]
fn vanilla_models_bake_without_extra_face_data() {
use crate::bake::bake_block_model;
let library = library(&[(
"block/plain",
r##"{"textures":{"a":"block/a"},"elements":[{"from":[0,0,0],"to":[16,16,16],"light_emission":6,"faces":{"up":{"texture":"#a"}}}]}"##,
)]);
let model = library.resolve("block/plain").unwrap();
let quad = &bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))
.unwrap()[0];
assert_eq!((quad.light, quad.light_emission), ([6, 6], 6));
assert_eq!(
(quad.color, quad.ambient_occlusion, quad.render_type),
(None, None, None)
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
("block/mid", r#"{"parent":"block/base"}"#),
(
"block/leaf",
r#"{"parent":"block/mid","render_type":"minecraft:cutout_mipped"}"#,
),
("block/bare", r#"{"parent":"block/mid"}"#),
]);
let render = |name| library.resolve(name).unwrap().render_type;
assert_eq!(render("block/leaf").as_deref(), Some("cutout_mipped"));
assert_eq!(render("block/bare").as_deref(), Some("translucent"));
assert_eq!(render("block/absent"), None);
assert_eq!(
crate::bake::RenderType::by_name("minecraft:tripwire"),
Some(crate::bake::RenderType::Tripwire)
);
assert_eq!(crate::bake::RenderType::by_name("mod:glow"), None);
}
#[test]
fn neoforge_data_colour_and_legacy_emission_spellings() {
let model = CuboidModel::from_json(
r##"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{
"up":{"texture":"#a","neoforge_data":{"color":"#80FF0000","light_emission":7}},
"down":{"texture":"#a","neoforge_data":{"color":4294967295,"block_light":99}}}}]}"##,
)
.unwrap();
let elements = model.elements.unwrap();
let faces = &elements[0].as_ref().unwrap().faces;
let up = faces[&Direction::Up]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(up.color, up.block_light, up.sky_light),
(Some(0x80FF0000u32 as i32), Some(7), Some(7))
);
let down = faces[&Direction::Down]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(down.color, down.block_light, down.sky_light),
(Some(-1), Some(15), None)
);
}
}
+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+97 -19
View File
@@ -125,7 +125,11 @@ impl Filter {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
// An allow entry with no letters would allow every word (an empty prefix).
let runs = normalise::word(word);
if !runs.is_empty() {
f.allowed.push((runs, prefix));
}
continue;
}
let (kind, word) = match line.as_bytes()[0] {
@@ -239,8 +243,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +327,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +384,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +427,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +474,34 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+82 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
@@ -251,3 +319,11 @@ fn a_message_costs_little() {
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
#[test]
fn an_allow_line_without_letters_allows_nothing() {
for line in ["!*", "!-*", "!", "!-"] {
let f = Filter::new(&format!("nigger\n{line}\n"));
assert!(f.matches("nigger"), "{line}");
}
}
+111 -5
View File
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
Some("custom") => Some(crate::text::ClickEvent::Custom {
id: crate::identifier::parse_identifier(&text("id")?)?,
payload: e
.get("payload")
.filter(|value| !value.is_null())
.map(crate::scoreboard::json_to_tag),
}),
_ => None,
}
}
@@ -257,10 +264,7 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
style.color = named_color(c).or_else(|| hex_color(c));
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
style.bold = style_flag(o.get("bold"));
@@ -303,6 +307,13 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style
}
/// `TextColor.parseColor` for `#...`: `Integer.parseInt(rest, 16)` (so a sign
/// and any digit count are accepted), kept only within `0..=0xFFFFFF`.
pub(crate) fn hex_color(c: &str) -> Option<u32> {
let value = i64::from_str_radix(c.strip_prefix('#')?, 16).ok()?;
(0..=0xFF_FFFF).contains(&value).then_some(value as u32)
}
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
if let Some(packed) = value.as_i64() {
@@ -485,6 +496,33 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn hex_color_follows_text_color_parse_color() {
for bad in [
"#1000000",
"#FFFFFFFF",
"#-FF",
"#",
"#zz",
"FFFFFF",
"#99999999999",
] {
assert_eq!(hex_color(bad), None, "{bad}");
}
for (good, want) in [
("#FFFFFF", 0xFFFFFF),
("#000000", 0),
("#F", 15),
("#+FF", 255),
("#-0", 0),
] {
assert_eq!(hex_color(good), Some(want), "{good}");
}
let out = serde_json::json!({"text": "A", "color": "#1000000"});
let text = to_text(&out, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.color, None);
}
#[test]
fn shadow_color_accepts_packed_and_rgba_styles() {
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
@@ -585,6 +623,63 @@ mod tests {
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
use crate::text::ClickEvent;
use horizon_world::nbt::Tag;
for (payload, expected) in [
(None, None),
(Some(serde_json::Value::Null), None),
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
(
Some(serde_json::json!({"shop": "potions", "amount": 3})),
Some(Tag::Compound(
[
("amount".into(), Tag::Int(3)),
("shop".into(), Tag::String("potions".into())),
]
.into_iter()
.collect(),
)),
),
] {
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
if let Some(payload) = payload {
event["payload"] = payload;
}
let component = serde_json::json!({
"text": "", "click_event": event,
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
});
let text = to_text(&component, &mut |_| Text::empty());
for (_, style) in text.chars() {
assert_eq!(
style.click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: expected.clone(),
})
);
}
let encoded = text.component();
let decoded = to_text(&encoded, &mut |_| Text::empty());
assert_eq!(decoded, text);
if expected.is_none() {
assert!(!encoded.to_string().contains("\"payload\""));
}
}
for id in ["Bad ID", "Uppercase:shop"] {
let text = to_text(
&serde_json::json!({
"text": "Shop", "click_event": {"action": "custom", "id": id}
}),
&mut |_| Text::empty(),
);
assert_eq!(text.spans[0].style.click, None);
}
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
@@ -624,4 +719,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+51 -12
View File
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
RULES.iter().find(|r| r.name == name)
available_rules(true).find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -160,7 +192,9 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name) else { return false };
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -176,8 +210,7 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
RULES
.iter()
self.available_rules()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -189,8 +222,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -212,11 +245,14 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
let mut rules = Self::default();
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let Some(data) = root.get("data") else {
return rules;
};
for r in RULES {
for r in rules.available_rules() {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -232,15 +268,18 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::default();
return Self::with_features(features);
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::default();
return Self::with_features(features);
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
+340 -2
View File
@@ -3,6 +3,7 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -34,6 +35,15 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -69,9 +79,243 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/hi_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hi_in.json")
}
"assets/horizon/lang/id_id.json" => {
include_str!("../../horizon-client/assets/horizon/lang/id_id.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_pt.json")
}
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/fa_ir.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fa_ir.json")
}
"assets/horizon/lang/he_il.json" => {
include_str!("../../horizon-client/assets/horizon/lang/he_il.json")
}
"assets/horizon/lang/hu_hu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hu_hu.json")
}
"assets/horizon/lang/ro_ro.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ro_ro.json")
}
"assets/horizon/lang/da_dk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/da_dk.json")
}
"assets/horizon/lang/fi_fi.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fi_fi.json")
}
"assets/horizon/lang/no_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/no_no.json")
}
"assets/horizon/lang/bg_bg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bg_bg.json")
}
"assets/horizon/lang/sk_sk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sk_sk.json")
}
"assets/horizon/lang/lt_lt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lt_lt.json")
}
"assets/horizon/lang/it_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/it_it.json")
}
"assets/horizon/lang/nl_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/nl_nl.json")
}
"assets/horizon/lang/vi_vn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vi_vn.json")
}
"assets/horizon/lang/th_th.json" => {
include_str!("../../horizon-client/assets/horizon/lang/th_th.json")
}
"assets/horizon/lang/ja_jp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ja_jp.json")
}
"assets/horizon/lang/ar_sa.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ar_sa.json")
}
"assets/horizon/lang/pt_br.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_br.json")
}
"assets/horizon/lang/cs_cz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cs_cz.json")
}
"assets/horizon/lang/sv_se.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sv_se.json")
}
"assets/horizon/lang/el_gr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/el_gr.json")
}
"assets/horizon/lang/ca_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ca_es.json")
}
"assets/horizon/lang/et_ee.json" => {
include_str!("../../horizon-client/assets/horizon/lang/et_ee.json")
}
"assets/horizon/lang/lv_lv.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lv_lv.json")
}
"assets/horizon/lang/sl_si.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sl_si.json")
}
"assets/horizon/lang/sr_cs.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_cs.json")
}
"assets/horizon/lang/is_is.json" => {
include_str!("../../horizon-client/assets/horizon/lang/is_is.json")
}
"assets/horizon/lang/af_za.json" => {
include_str!("../../horizon-client/assets/horizon/lang/af_za.json")
}
"assets/horizon/lang/ms_my.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ms_my.json")
}
"assets/horizon/lang/be_by.json" => {
include_str!("../../horizon-client/assets/horizon/lang/be_by.json")
}
"assets/horizon/lang/sr_sp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_sp.json")
}
"assets/horizon/lang/az_az.json" => {
include_str!("../../horizon-client/assets/horizon/lang/az_az.json")
}
"assets/horizon/lang/uz_uz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uz_uz.json")
}
"assets/horizon/lang/kk_kz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kk_kz.json")
}
"assets/horizon/lang/ka_ge.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ka_ge.json")
}
"assets/horizon/lang/hy_am.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hy_am.json")
}
"assets/horizon/lang/bs_ba.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bs_ba.json")
}
"assets/horizon/lang/mk_mk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mk_mk.json")
}
"assets/horizon/lang/sq_al.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sq_al.json")
}
"assets/horizon/lang/cy_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cy_gb.json")
}
"assets/horizon/lang/ga_ie.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ga_ie.json")
}
"assets/horizon/lang/eu_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eu_es.json")
}
"assets/horizon/lang/gl_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gl_es.json")
}
"assets/horizon/lang/fil_ph.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fil_ph.json")
}
"assets/horizon/lang/ta_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ta_in.json")
}
"assets/horizon/lang/mn_mn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mn_mn.json")
}
"assets/horizon/lang/lo_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lo_la.json")
}
"assets/horizon/lang/so_so.json" => {
include_str!("../../horizon-client/assets/horizon/lang/so_so.json")
}
"assets/horizon/lang/fy_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fy_nl.json")
}
"assets/horizon/lang/gd_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gd_gb.json")
}
"assets/horizon/lang/lb_lu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lb_lu.json")
}
"assets/horizon/lang/ky_kg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ky_kg.json")
}
"assets/horizon/lang/kn_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kn_in.json")
}
"assets/horizon/lang/mt_mt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mt_mt.json")
}
"assets/horizon/lang/eo_uy.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eo_uy.json")
}
"assets/horizon/lang/haw_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/haw_us.json")
}
"assets/horizon/lang/ig_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ig_ng.json")
}
"assets/horizon/lang/la_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/la_la.json")
}
"assets/horizon/lang/tt_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tt_ru.json")
}
"assets/horizon/lang/yi_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yi_de.json")
}
"assets/horizon/lang/yo_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yo_ng.json")
}
"assets/horizon/lang/ba_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ba_ru.json")
}
"assets/horizon/lang/br_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/br_fr.json")
}
"assets/horizon/lang/cv_cu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cv_cu.json")
}
"assets/horizon/lang/fo_fo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fo_fo.json")
}
"assets/horizon/lang/fur_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fur_it.json")
}
"assets/horizon/lang/li_li.json" => {
include_str!("../../horizon-client/assets/horizon/lang/li_li.json")
}
"assets/horizon/lang/lmo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lmo.json")
}
"assets/horizon/lang/oc_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/oc_fr.json")
}
"assets/horizon/lang/sah_sah.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sah_sah.json")
}
"assets/horizon/lang/se_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/se_no.json")
}
"assets/horizon/lang/szl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/szl.json")
}
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
@@ -254,8 +498,50 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -267,13 +553,19 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| key.to_owned())
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -350,6 +642,52 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+4 -7
View File
@@ -107,10 +107,7 @@ pub fn style_from_json(v: &Value) -> Style {
let mut s = Style::default();
let Some(o) = v.as_object() else { return s };
if let Some(c) = o.get("color").and_then(Value::as_str) {
s.color = text::named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
s.color = text::named_color(c).or_else(|| component::hex_color(c));
}
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
@@ -1341,9 +1338,9 @@ fn decompose(c: &Comp) -> (serde_json::Map<String, Value>, Vec<Comp>) {
}
"color" => {
let color = v.as_str().map(|s| match s.strip_prefix('#') {
Some(hex) => match u32::from_str_radix(hex, 16) {
Ok(rgb) => format!("#{rgb:06X}"),
Err(_) => s.to_owned(),
Some(_) => match component::hex_color(s) {
Some(rgb) => format!("#{rgb:06X}"),
None => s.to_owned(),
},
None => s.to_owned(),
});
+36 -2
View File
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
}
/// Click actions carried by locally constructed text components.
/// Click actions carried by text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
@@ -53,6 +53,11 @@ pub enum ClickEvent {
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
Custom {
id: String,
payload: Option<horizon_world::nbt::Tag>,
},
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -241,6 +246,28 @@ impl Text {
t.source = Some(Value::Object(c).into());
t
}
/// Component.translatableWithFallback(key, fallback, args): `translate`, with `fallback` as
/// the template when the language has no `key`.
pub fn translate_with_fallback(key: &str, fallback: &str, args: &[Text]) -> Self {
let template = lang::current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned());
let mut t = format_translation(&template, args);
let mut c = Map::new();
c.insert("translate".into(), key.into());
c.insert("fallback".into(), fallback.into());
if !args.is_empty() {
c.insert("with".into(), args.iter().map(Text::argument).collect());
}
t.source = Some(Value::Object(c).into());
t
}
/// CommonComponents.optionNameValue: "Caption: value" joined as the language joins them
/// (`options.generic_value`: "%s : %s" in French, "%s:%s" in Chinese). English's "%s: %s"
/// when the language has no such key, as without prepared resources.
pub fn option_name_value(caption: Text, value: Text) -> Self {
Self::translate_with_fallback("options.generic_value", "%s: %s", &[caption, value])
}
/// A translatable message whose arguments are plain strings.
pub fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> Self {
let args: Vec<Text> = args.iter().map(|a| Text::literal(a.to_string())).collect();
@@ -539,7 +566,7 @@ const STYLE_KEYS: [&str; 11] = [
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
/// `Style.Serializer`: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(c) = style.color {
@@ -581,6 +608,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::Custom { id, payload } => {
let mut event = json!({ "action": "custom", "id": id });
if let Some(payload) = payload {
event["payload"] = crate::scoreboard::tag_to_json(payload);
}
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+37 -2
View File
@@ -12,13 +12,48 @@ pub fn uuid_text(u: [i32; 4]) -> String {
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
let mut hex = [0u8; 32];
let mut len = 0;
for b in s.bytes() {
if b == b'-' {
continue;
}
if !b.is_ascii_hexdigit() || len >= 32 {
return None;
}
hex[len] = b;
len += 1;
}
if len != 32 {
return None;
}
let hex = std::str::from_utf8(&hex).ok()?;
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_from_text_rejects_multibyte_and_non_hex() {
assert_eq!(uuid_from_text("€€€€€€€€€€aa"), None);
assert_eq!(uuid_from_text("€€€€€€€€€€aa----"), None);
assert_eq!(uuid_from_text("+0000001-0000-0000-0000-000000000000"), None);
assert_eq!(uuid_from_text("00000000-0000-0000-0000-00000000000g"), None);
assert_eq!(uuid_from_text(""), None);
}
#[test]
fn uuid_from_text_parses_hex_and_dashes() {
let u = [0x01234567, -0x76543211, 0x01234567, -0x76543211];
let text = uuid_text(u);
assert_eq!(uuid_from_text(&text), Some(u));
let undashed: String = text.chars().filter(|c| *c != '-').collect();
assert_eq!(uuid_from_text(&undashed), Some(u));
}
}
@@ -51,6 +51,10 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
+203 -2
View File
@@ -182,13 +182,38 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -218,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -248,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -271,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -678,6 +745,140 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+14
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -61,6 +63,10 @@ ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
# The network threads of a server's players: epoll, kqueue or Windows' AFD (lan/io_pool.rs).
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
@@ -132,6 +138,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
@@ -219,3 +229,7 @@ path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
[[example]]
name = "chunk_encoding_bench"
path = "../horizon-client/examples/chunk_encoding_bench.rs"
+10
View File
@@ -7,6 +7,9 @@ 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-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
@@ -40,6 +43,10 @@ miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
# Project icons served by Modrinth are often WebP or JPEG rather than PNG, and
# the pack browser showed nothing for those. Both are small pure-Rust decoders.
image-webp = { version = "0.2", default-features = false }
zune-jpeg = { version = "0.4", default-features = false, features = ["std"] }
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
@@ -68,6 +75,9 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -0,0 +1,221 @@
//! Original synthetic capability regression; no game resources or player world.
//! Run with fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
//! HORIZON_NO_AUDIO=1 and HORIZON_LAUNCHER_NO_SELF_UPDATE=1.
use horizon_client_render::{gfx::Renderer, graphics::GraphicsApi};
use std::{io::Write, sync::Arc};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
const COLOUR: &str = "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n";
// Reading the actual shadow sampler activates the shadow pass, rather than just
// placing a shadow program in the archive and assuming the pass runs.
const SHADOW_READ: &str = "#version 120\nuniform sampler2D shadowtex0;\n\
void main() { gl_FragColor = vec4(vec3(texture2D(shadowtex0, vec2(0.5)).r), 1.0); }\n";
fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_shaders::Pack {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut write = |name: &str, text: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
};
write("shaders.properties", properties);
write("final.vsh", VERTEX);
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
write("gbuffers_entities.vsh", VERTEX);
write("gbuffers_entities.fsh", COLOUR);
if let Some(shadow) = shadow {
write("shadow.vsh", VERTEX);
write("shadow.fsh", shadow);
}
horizon_shaders::Pack::from_zip_bytes(
"original-shadow-ownership",
zip.finish().unwrap().into_inner(),
)
.unwrap()
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let white = [255; 4];
let panorama = Arc::new(horizon_client_render::gfx::panorama::Faces::colour([0; 4]));
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
1,
1,
1,
&[],
&[],
(&white, 1, 1),
(&white, 1, 1),
(&white, 1, 1),
(&white, 1),
panorama,
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
);
let cases = [
("full", "", Some(COLOUR), true, false, true),
(
"player-only",
"shadowEntities=false\nshadowPlayer=true",
Some(COLOUR),
true,
false,
false,
),
// Block-entity shadows alone must not suppress a mob's blob.
(
"blocks-only",
"shadowEntities=false",
Some(COLOUR),
true,
false,
false,
),
(
"disabled",
"shadow.enabled=false",
Some(COLOUR),
true,
false,
false,
),
("missing-program", "", None, true, false, false),
(
"failed-translation",
"",
Some("#version 120\nvoid main() { gl_FragColor = deliberately_undefined; }"),
true,
false,
false,
),
("failed-pipeline", "", Some(COLOUR), true, true, false),
(
"unused-shadow-program",
"",
Some(COLOUR),
false,
false,
false,
),
(
"forced-shadow-program",
"shadow.enabled=true",
Some(COLOUR),
false,
false,
true,
),
];
for dimension in [0, -1, 1] {
for (name, properties, shadow, reads_shadow, break_pipeline, expected) in cases {
let pack = pack(properties, shadow, reads_shadow);
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let mut post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&renderer.pack_inputs(),
);
if name == "failed-translation" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("skipped")));
} else if name == "missing-program" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("no program")));
} else if break_pipeline {
// A translated stage can still fail device pipeline creation. Inject a
// missing entry point into only the entity caster; terrain/block stages
// and the settings stay valid. PackPost must report the compiled result.
post.shadow_entities
.as_mut()
.unwrap()
.program
.as_mut()
.unwrap()
.vertex
.entry_points[0]
.name = "deliberately_missing_main".into();
} else {
assert!(post.notes.is_empty(), "{name}: {:?}", post.notes);
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap();
if break_pipeline {
assert!(
notes.iter().any(|note| note.contains("skipped")),
"{notes:?}"
);
} else if name != "missing-program" && name != "failed-translation" {
assert!(
!notes.iter().any(|note| note.contains("skipped")),
"{name}: {notes:?}"
);
}
assert_eq!(
renderer.pack_entity_shadows(),
expected,
"{name}, dimension {dimension}: {notes:?}"
);
assert!(!renderer.native_shadow());
println!("{name}, dimension {dimension}: replaces_mob_blob={expected}");
}
}
renderer.drop_shader_packs();
assert!(
!renderer.pack_entity_shadows(),
"dropped pack restores fallback"
);
println!("ENTITY_SHADOW_OWNERSHIP_PASSED: 27 compiled controls and no/pending/dropped pack");
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(
Window::default_attributes()
.with_visible(false)
.with_inner_size(winit::dpi::LogicalSize::new(32., 32.)),
)
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("Shadow ownership fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
@@ -0,0 +1,827 @@
//! Draws a scene `horizon-ctl render-capture DIR` wrote, offscreen, from its camera, with the
//! real `Renderer`, and prints GPU and CPU milliseconds per frame and Horizon's own passes'
//! GPU times as JSON (docs/graphics.md, "Render capture and replay").
//! `cargo run --release -p horizon-client-render --example scene_replay -- DIR [--frames 300]
//! [--warmup 60] [--yaw-offsets 0,90,180,270] [--texture-filtering 0|1|2] [--anisotropy-bit N]
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
//! [--world SAVE --leaf-culling MODE] [--memory on]`
//! `--screenshot` saves the first view's last frame, after the timed frames.
//! `--world` meshes the captured sections again from that save's region files
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
//! change can be measured on the captured view. A section whose 3x3 columns are
//! not all saved keeps its captured mesh.
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
//! physical footprint by category (`footprint`), taken after the timed frames.
use horizon_client_render::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, block_strategy},
fast_hash::{FxHashMap, FxHashSet},
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
panorama::Faces,
},
graphics::LeafCullingMode,
hud::HudBatch,
lod::TileKey,
mesher::{MeshScratch, MeshTables, Mesher, SectionMesh, packed},
models,
};
use horizon_core::{
biome::{BiomeZoom, obfuscate_seed},
paletted::PalettedContainer,
};
use horizon_world::{
chunk::BlockState,
nbt::{Limits, NamedTag, Tag},
region::RegionFile,
registry::BlockRegistry,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
// gigabytes of the meshes `--world` frees dirty.
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
#[derive(Default)]
struct Options {
dir: PathBuf,
frames: usize,
warmup: usize,
yaw_offsets: Vec<f32>,
root: Option<PathBuf>,
texture_filtering: Option<i32>,
anisotropy_bit: Option<i32>,
mipmaps: Option<u32>,
lod_distance: Option<u32>,
render_distance: Option<i32>,
size: Option<[u32; 2]>,
flat: Option<bool>,
cull: Option<bool>,
sides: Option<bool>,
gpu_culling: Option<bool>,
screenshot: Option<PathBuf>,
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
match value {
"on" | "flat" => Ok(true),
"off" | "indirect" => Ok(false),
other => Err(format!("expected on or off, got {other}")),
}
}
fn options() -> Result<Options, String> {
let mut o = Options {
frames: 300,
warmup: 60,
yaw_offsets: vec![0.0],
..Default::default()
};
let mut args = std::env::args().skip(1);
let mut dir = None;
while let Some(arg) = args.next() {
if !arg.starts_with("--") {
dir = Some(PathBuf::from(arg));
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |v: &str| v.parse::<i64>().map_err(|e| format!("{arg}: {e}"));
match arg.as_str() {
"--frames" => o.frames = number(&value)?.max(1) as usize,
"--warmup" => o.warmup = number(&value)?.max(0) as usize,
"--yaw-offsets" => {
o.yaw_offsets = value
.split(',')
.map(|v| v.trim().parse::<f32>().map_err(|e| format!("{arg}: {e}")))
.collect::<Result<_, _>>()?
}
"--root" => o.root = Some(value.into()),
"--texture-filtering" => o.texture_filtering = Some(number(&value)? as i32),
"--anisotropy-bit" => o.anisotropy_bit = Some(number(&value)? as i32),
"--mipmaps" => o.mipmaps = Some(number(&value)?.max(0) as u32),
"--lod-distance" => o.lod_distance = Some(number(&value)?.max(0) as u32),
"--render-distance" => o.render_distance = Some(number(&value)?.max(0) as i32),
"--size" => {
let (w, h) = value.split_once('x').ok_or("--size takes WxH")?;
o.size = Some([number(w)? as u32, number(h)? as u32]);
}
"--section-draws" => o.flat = Some(on_off(&value)?),
"--section-cull" => o.cull = Some(on_off(&value)?),
"--section-sides" => o.sides = Some(on_off(&value)?),
"--gpu-culling" => o.gpu_culling = Some(on_off(&value)?),
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
.find(|mode| mode.name() == value)
.ok_or(format!("no leaf culling mode {value}"))?;
}
other => return Err(format!("unknown option {other}")),
}
}
o.dir = dir.ok_or("the capture directory is required")?;
Ok(o)
}
/// Milliseconds: average, median, 95th percentile and slowest, over `samples`.
fn summary(samples: &[f32]) -> Value {
if samples.is_empty() {
return Value::Null;
}
let mut sorted = samples.to_vec();
sorted.sort_by(f32::total_cmp);
let at = |q: f32| sorted[((sorted.len() - 1) as f32 * q).round() as usize];
json!({
"avg": samples.iter().sum::<f32>() / samples.len() as f32,
"min": sorted[0],
"p10": at(0.1),
"p50": at(0.5),
"p95": at(0.95),
"max": sorted[sorted.len() - 1],
"samples": samples.len(),
})
}
fn ms(d: Duration) -> f32 {
d.as_secs_f32() * 1000.0
}
fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let o = options()?;
let scene = Scene::read(&o.dir)?;
let settings = &scene.settings;
let root = o.root.clone().unwrap_or_else(|| settings.resources.clone());
let loading = Instant::now();
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
if AtlasCheck::of(&render) != settings.atlas {
return Err(format!(
"the block sprites at {} are not the ones the capture was made with (resource packs \
or mods?): pass --root with the game's resource root",
root.display()
)
.into());
}
let one = [255u8; 4];
let zero = [0u8; 4];
let prepared = PreparedTextures::new(
&render.pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
(&zero[..], 1),
)
.with_pbr(
&render.pbr_normals,
&render.pbr_specular,
render.pbr_size,
render.texture_layers,
);
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
&render.sprite_mips,
&render.animations,
(&one[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1),
Arc::new(Faces::colour([0, 0, 0, 255])),
Some(prepared),
None,
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
renderer.gamma = state.gamma;
renderer.fullbright = state.fullbright;
renderer.improved_transparency = state.improved_transparency;
renderer.animated_textures = state.animated_textures;
let filtering = o.texture_filtering.unwrap_or(settings.texture_filtering);
let anisotropy = o.anisotropy_bit.unwrap_or(settings.anisotropy_bit);
renderer.set_texture_filtering(filtering, anisotropy);
renderer.set_mipmap_levels(o.mipmaps.unwrap_or(settings.mipmap_levels));
renderer.set_world_post(
settings.resolution_scale,
settings.fxaa,
settings.taa,
settings.upscale,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
if let Err(e) = result {
notes.push(format!("{what}: {e}"));
}
};
note(
"section-draws",
renderer.set_flat_draws(o.flat.unwrap_or(state.flat_draws)),
);
note(
"section-cull",
renderer.set_section_cull(o.cull.unwrap_or(state.section_cull)),
);
note(
"gpu-culling",
renderer.set_gpu_culling(o.gpu_culling.unwrap_or(state.gpu_culling)),
);
renderer.set_section_sides(o.sides.unwrap_or(state.section_sides));
renderer.set_pass_timing(true);
let layout = ColumnLayout::of_level(state.min_y, state.sections as i32 * 16);
if layout != ColumnLayout::VANILLA {
renderer.clear_sections(layout);
}
// Near sections first: with no far tile resident yet, none fades in.
let camera = scene.frame.camera;
let column = |x: f64| (x.floor() as i32) >> 4;
let (cx, cz) = (column(camera[0]), column(camera[2]));
let near = |x: i32, z: i32| {
o.render_distance
.is_none_or(|r| (x - cx).pow(2) + (z - cz).pow(2) <= r * r)
};
let (mut remeshed, mut world_report) = (FxHashMap::default(), Value::Null);
if let Some(world) = &o.world {
let keys: Vec<[i32; 3]> = section_keys(&o.dir)?
.into_iter()
.filter(|&[x, _, z]| near(x, z))
.collect();
let started = Instant::now();
remeshed = remesh(world, &root, &assets, &render, o.leaf_culling, &keys)?;
world_report = json!({
"save": world.display().to_string(),
"leaf_culling": o.leaf_culling.name(),
"remeshed": remeshed.len(),
"kept": keys.len() - remeshed.len(),
"remesh_ms": ms(started.elapsed()),
});
}
let leaf_layers = leaf_layers(&render);
let (mut sections, mut quads, mut leaf_quads) = (0, 0usize, 0usize);
let mut packed_quads = 0usize;
capture::read_meshes(&o.dir, "sections", |[x, y, z], captured| {
if near(x, z) {
let mesh = remeshed.remove(&[x, y, z]).unwrap_or(captured);
quads += mesh.solid.len() / 4;
packed_quads += packed::packed_quads(&mesh.packed, mesh.solid.len() / 4);
leaf_quads += mesh
.solid
.iter()
.filter(|v| leaf_layers.contains(&v.layer))
.count()
/ 4;
renderer.upload((x, y, z), &mesh);
sections += 1;
}
})?;
let mut tiles: Vec<(TileKey, SectionMesh)> = Vec::new();
capture::read_meshes(&o.dir, "tiles", |[x, z, level], mesh| {
let key = TileKey {
x,
z,
level: level as u8,
};
let [ox, _, oz] = key.origin();
let half = f64::from(key.size()) / 2.0;
let (dx, dz) = (
f64::from(ox) + half - camera[0],
f64::from(oz) + half - camera[2],
);
let near = o
.lod_distance
.is_none_or(|d| (dx * dx + dz * dz).sqrt() <= f64::from(d) * 16.0);
if near {
tiles.push((key, mesh));
}
})?;
let tile_count = tiles.len();
if !tiles.is_empty() {
let cutout: FxHashSet<(i32, i32)> = scene.lod_cutout.iter().copied().collect();
renderer.queue_lods(tiles, Vec::new(), Some(cutout));
renderer.upload_lods(Duration::from_secs(3600));
}
let loaded_ms = ms(loading.elapsed());
let hud = HudBatch::new([width, height]);
let mut views = Vec::new();
for &offset in &o.yaw_offsets {
let mut frame = scene.frame(&hud);
frame.yaw += offset;
frame.eye_rotation[0] += offset;
if let Some(r) = o.render_distance {
frame.distance = (r * 16) as f32;
frame.detail_distance = frame.distance;
}
let draw = |renderer: &mut Renderer| -> horizon_client_render::Result<Duration> {
if frame.cave_culling {
renderer.start_occlusion(frame.camera, frame.distance);
}
let started = Instant::now();
renderer.render(&frame)?;
Ok(started.elapsed())
};
for _ in 0..o.warmup {
draw(&mut renderer)?;
}
renderer.gpu_times.clear();
let (mut call, mut encode, mut gpu) = (Vec::new(), Vec::new(), Vec::new());
for _ in 0..o.frames {
call.push(ms(draw(&mut renderer)?));
encode.push(ms(renderer.last_encode));
gpu.append(&mut renderer.gpu_times);
}
// Timestamps arrive a few frames late: a few more of the same frame bring them in.
for _ in 0..16 {
if gpu.len() >= o.frames {
break;
}
draw(&mut renderer)?;
gpu.append(&mut renderer.gpu_times);
}
gpu.truncate(o.frames);
if let Some(path) = o.screenshot.as_ref().filter(|_| views.is_empty()) {
renderer.screenshot_result = None;
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
if renderer.screenshot_result.is_some() {
break;
}
}
match renderer.screenshot_result.take() {
Some(Ok(_)) => {}
Some(Err(e)) => notes.push(format!("screenshot: {e}")),
None => notes.push("screenshot: not saved".into()),
}
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
"render_call_ms": summary(&call),
"cpu_encode_ms": summary(&encode),
"gpu_passes_median_ms": renderer
.own_pass_times()
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect::<serde_json::Map<_, _>>(),
"drawn_sections": renderer.drawn_sections,
"section_draws": renderer.section_draws,
"section_draw_calls": renderer.section_draw_calls,
// Occlusion culling's last result: boxes hidden, boxes tested, share of solid
// indices hidden.
"culled": renderer.culled_sections(),
}));
}
let section_draws = if renderer.flat_draws() {
"flat"
} else {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
"captured_on": state.adapter,
"size": [width, height],
"sections": sections,
"solid_quads": quads,
"leaf_quads": leaf_quads,
// Solid quads uploaded as one record each (`mesher::packed`).
"packed_quads": packed_quads,
"world": world_report,
"tiles": tile_count,
"load_ms": loaded_ms,
"frames": o.frames,
"warmup": o.warmup,
"settings": {
"texture_filtering": filtering,
"anisotropy_bit": anisotropy,
"mipmaps": o.mipmaps.unwrap_or(settings.mipmap_levels),
"lod_distance": o.lod_distance,
"render_distance": o.render_distance,
"section_draws": section_draws,
"section_cull": renderer.section_cull(),
"section_sides": renderer.section_sides(),
"gpu_culling": renderer.gpu_culling(),
"shader_pack_left_out": state.shader_pack,
},
"notes": notes,
"views": views,
"memory": memory,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn mb(bytes: u64) -> f64 {
(bytes as f64 / 1048576.0 * 10.0).round() / 10.0
}
/// `--memory`: the renderer's census in MiB and, on macOS, `footprint`'s summary of this
/// process (GPU buffers and textures count as IOAccelerator memory there).
fn memory_report(renderer: &Renderer) -> Value {
let gpu: serde_json::Map<String, Value> = renderer
.gpu_census()
.into_iter()
.map(|(name, bytes)| (format!("{name}_mb"), json!(mb(bytes))))
.collect();
let footprint = std::process::Command::new("/usr/bin/footprint")
.args(["-f", "bytes", "-p", &std::process::id().to_string()])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
let footprint = footprint.map(|text| {
let mut total = None;
let mut categories = Vec::new();
for line in text.lines() {
if let Some(at) = line.find("Footprint: ") {
total = line[at + 11..]
.split_whitespace()
.next()
.and_then(|v| v.parse::<u64>().ok());
}
// " <dirty> B <clean> B <reclaimable> B <regions> <category>"
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() >= 8
&& words[1] == "B"
&& let Ok(dirty) = words[0].parse::<u64>()
&& dirty >= 1 << 20
{
categories.push(json!([words[7..].join(" "), mb(dirty)]));
}
}
json!({ "total_mb": total.map(mb), "dirty_by_category_mb": categories })
});
json!({ "gpu": gpu, "footprint": footprint })
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = replay(window) {
eprintln!("scene_replay: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
/// The captured sections' keys, from the index alone.
fn section_keys(dir: &Path) -> Result<Vec<[i32; 3]>, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize)]
struct Key {
key: [i32; 3],
}
let records: Vec<Key> = serde_json::from_reader(std::io::BufReader::new(std::fs::File::open(
dir.join("sections.json"),
)?))?;
Ok(records.into_iter().map(|r| r.key).collect())
}
/// Sprite layers of every leaves block, as terrain vertices carry them (with or
/// without `OPAQUE_LAYER`).
fn leaf_layers(render: &RenderAssets) -> FxHashSet<u16> {
render
.sprite_layers
.iter()
.filter(|(name, _)| name.ends_with("_leaves"))
.flat_map(|(_, &layer)| {
[
layer as u16,
layer as u16 | horizon_client_render::mesher::OPAQUE_LAYER,
]
})
.collect()
}
/// `--world`: the sections `keys` meshed again from `save`'s overworld region
/// files, on every core, as the game's workers mesh them.
fn remesh(
save: &Path,
root: &Path,
assets: &Assets,
render: &RenderAssets,
leaf_culling: LeafCullingMode,
keys: &[[i32; 3]],
) -> Result<FxHashMap<[i32; 3], SectionMesh>, Box<dyn std::error::Error>> {
let facts = BlockFacts::load(root, assets.registry)?;
let visibility = models::load_visibility(root, assets.registry)?;
let tables = MeshTables::new(assets, render, &facts, visibility)?;
let biomes = Biomes::load(root)?;
let seed = world_seed(save).unwrap_or_else(|| {
eprintln!(
"scene_replay: no seed in {}, biome blending uses 0",
save.display()
);
0
});
let options = std::fs::read_to_string("options.txt").unwrap_or_default();
let option = |key: &str| {
options
.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix(':').map(str::to_owned))
};
let smooth_lighting = option("ao").is_none_or(|v| v == "true");
let cutout_leaves = option("cutoutLeaves").is_none_or(|v| v == "true");
let blend_radius = option("biomeBlendRadius")
.and_then(|v| v.parse().ok())
.unwrap_or(2);
let mut columns: Vec<(i32, i32)> = keys
.iter()
.flat_map(|&[x, _, z]| {
(-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)))
})
.collect();
columns.sort_unstable();
columns.dedup();
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let region_dir = save.join("dimensions/minecraft/overworld/region");
let next = AtomicUsize::new(0);
let chunks: FxHashMap<(i32, i32), Chunk> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut files: FxHashMap<(i32, i32), Option<RegionFile>> = FxHashMap::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&(x, z)) = columns.get(i) else {
break;
};
let file = files.entry((x >> 5, z >> 5)).or_insert_with(|| {
RegionFile::open(region_dir.join(format!(
"r.{}.{}.mca",
x >> 5,
z >> 5
)))
.ok()
});
let root = file
.as_mut()
.and_then(|f| {
f.read(horizon_core::position::ChunkPos::new(x, z))
.ok()
.flatten()
})
.and_then(|record| record.nbt(Limits::default()).ok());
if let Some(chunk) =
root.and_then(|root| decode(&root, assets.registry, &biomes))
{
out.push(((x, z), chunk));
}
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect()
});
let meshable: Vec<[i32; 3]> = keys
.iter()
.copied()
.filter(|&[x, _, z]| {
(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
})
.collect();
let next = AtomicUsize::new(0);
let meshes = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables: &tables,
biomes: &biomes,
visibility: tables.visibility.clone(),
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling,
};
let mut scratch = MeshScratch::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&[x, y, z]) = meshable.get(i) else {
break;
};
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
})
});
let mut mesh =
mesher.mesh_reusing(&region, y, SectionMesh::default(), &mut scratch);
mesh.sort_solid();
out.push(([x, y, z], mesh));
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect::<FxHashMap<_, _>>()
});
Ok(meshes)
}
/// The world seed: `data/minecraft/world_gen_settings.dat`, or level.dat's
/// `Data.WorldGenSettings` in older saves.
fn world_seed(save: &Path) -> Option<i64> {
let read = |path: PathBuf| {
horizon_world::datafix::files::read_dat(&path)
.ok()
.flatten()
};
let seed = |tag: Option<&Tag>| match tag?.get("seed")? {
Tag::Long(seed) => Some(*seed),
_ => None,
};
seed(
read(save.join("data/minecraft/world_gen_settings.dat"))
.as_ref()
.and_then(|t| t.get("data")),
)
.or_else(|| {
seed(
read(save.join("level.dat"))?
.get("Data")?
.get("WorldGenSettings"),
)
})
}
/// A saved full chunk's blocks, biomes and light, as the client's own save
/// loader reads them (`save::decode_lit`, in horizon-client).
fn decode(root: &NamedTag, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
let tag = &root.tag;
let text = |t: Option<&Tag>| match t {
Some(Tag::String(s)) => Some(s.to_string_lossy().to_string()),
_ => None,
};
if text(tag.get("Status")).is_some_and(|s| s != "minecraft:full" && s != "full") {
return None;
}
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return None;
}
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (tag.get("xPos"), tag.get("zPos")) else {
return None;
};
let mut chunk = Chunk::with_layout(*x, *z, ColumnLayout::VANILLA);
let air = registry.default_state("minecraft:air")?.0;
let Some(Tag::List(sections)) = tag.get("sections") else {
return Some(chunk);
};
let longs = |t: Option<&Tag>| match t {
Some(Tag::LongArray(data)) => Some(data.iter().map(|&w| w as u64).collect::<Vec<_>>()),
_ => None,
};
let mut layers = vec![(None, None); chunk.sections.len()];
for section in sections {
let Some(Tag::Byte(y)) = section.get("Y") else {
continue;
};
let Some(si) = chunk.section_index(i32::from(*y) * 16) else {
continue;
};
let target = Arc::make_mut(&mut chunk.sections[si]);
if let Some(states) = section.get("block_states")
&& let Some(Tag::List(palette)) = states.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| {
BlockState::from_nbt(p)
.ok()
.and_then(|s| {
registry.id(&s).or_else(|| {
let default = registry.default_state(&s.name)?;
let mut full = registry.state(default)?.clone();
for (k, v) in &s.properties {
if full.properties.contains_key(k) {
full.properties.insert(k.clone(), v.clone());
}
}
registry.id(&full).or(Some(default))
})
})
.map_or(air, |id| id.0)
})
.collect();
let data = longs(states.get("data"));
if let Ok(blocks) =
PalettedContainer::from_packed(block_strategy(), palette, data.as_deref())
{
target.blocks = blocks;
}
}
if let Some(b) = section.get("biomes")
&& let Some(Tag::List(palette)) = b.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| match p {
Tag::String(s) => u32::from(biomes.id(&s.to_string_lossy())),
_ => 0,
})
.collect();
let data = longs(b.get("data"));
if let Ok(container) =
PalettedContainer::from_packed(BIOME_STRATEGY, palette, data.as_deref())
{
target.biomes = container;
}
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(a.clone()),
_ => None,
};
layers[si] = (layer("SkyLight"), layer("BlockLight"));
}
// `save::load_light`: a section without saved sky light takes the light
// of the blocks above it, from the top of the column down.
let mut above = [15u8; 256];
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
let mut data = Box::new([0u8; 4096]);
for (i, v) in data.iter_mut().enumerate() {
let nibble =
|layer: &Option<Vec<u8>>| layer.as_ref().map(|l| l[i >> 1] >> ((i & 1) << 2) & 15);
let s = nibble(&sky).unwrap_or(above[i & 255]);
let b = nibble(&block).unwrap_or(0);
*v = s << 4 | b;
}
if sky.is_some() {
for (i, a) in above.iter_mut().enumerate() {
*a = data[i] >> 4;
}
}
let section = Arc::make_mut(&mut chunk.sections[si]);
section.light = Light::Full(data);
section.compact();
}
chunk.lit = true;
chunk.light_loaded = true;
Some(chunk)
}
@@ -0,0 +1,398 @@
//! Original asset-free standard-pack entity fixture. Run with throwaway HORIZON_HOME,
//! HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md).
//! `cargo run -p horizon-client-render --example standard_pack_gpu_entities -- <output-dir>`
use client::{
gfx::{
Frame, Renderer, ScreenshotRequest,
entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex},
native_render,
pack_draw::EntitySpan,
},
graphics::GraphicsApi,
hud::HudBatch,
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
static NEEDS_SPANS: AtomicBool = AtomicBool::new(false);
static OBSERVED: AtomicUsize = AtomicUsize::new(0);
struct Observer;
impl native_render::RenderHooks for Observer {
fn render_needs(&self, event: &mut native_render::RenderNeedsEvent<'_>) {
event.needs.entity_spans |= NEEDS_SPANS.load(Ordering::Relaxed);
}
fn world_drawn(&self, event: &mut native_render::WorldDrawnEvent<'_, '_>) {
if NEEDS_SPANS.load(Ordering::Relaxed) {
assert_eq!(event.world.entities.len(), 12);
assert_eq!(
event.world.spans.iter().map(|s| s.id).collect::<Vec<_>>(),
[77, 88, 99]
);
OBSERVED.fetch_add(1, Ordering::Relaxed);
}
}
}
fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let shaders = folder.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
shaders.join(format!("{program}.fsh")),
if valid {
fragment
} else {
"#version 120\nvoid main(){ invalid shader; }"
},
)
.unwrap();
}
std::fs::write(shaders.join("final.vsh"), vertex).unwrap();
std::fs::write(shaders.join("final.fsh"), "#version 120\nvarying vec2 uv; uniform sampler2D colortex0; void main(){gl_FragColor=texture2D(colortex0,uv);}\n").unwrap();
std::fs::write(
shaders.join("shaders.properties"),
"oldLighting=false\nshadow.enabled=false\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(folder).unwrap();
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
);
}
fn read(path: &Path) -> Vec<u8> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Observer])).map_err(|_| "observer already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
assert!(
renderer.skins_entities(),
"fixture requires compute skinning"
);
let unmodified_tagged = renderer.skins_tagged();
pack(&mut renderer, &out.join("valid-pack"), true);
assert!(
!renderer.skins_tagged(),
"eye/tagged layers keep the CPU path with a pack"
);
let spans = [77, 88, 99]
.into_iter()
.enumerate()
.map(|(i, id)| EntitySpan {
start: i as u32 * 4,
stage: horizon_shaders::render_stage("ENTITIES"),
id,
hidden: i == 2,
..Default::default()
})
.collect::<Vec<_>>();
let mut skin = SkinFrame {
reset: true,
vertices: 12,
spans: spans.clone(),
parts: vec![SkinPart {
matrix: glam::Mat4::IDENTITY.to_cols_array(),
normal: glam::Mat3::IDENTITY.to_cols_array(),
}],
..Default::default()
};
for (i, x) in [-1.8, 0.3, -0.7].into_iter().enumerate() {
for (pos, uv) in [
[x, -0.8, 5.],
[x, 0.8, 5.],
[x + 1.4, 0.8, 5.],
[x + 1.4, -0.8, 5.],
]
.into_iter()
.zip([[0., 0.], [0., 1.], [1., 1.], [1., 0.]])
{
skin.new_model.push(SkinVertex {
pos,
uv,
axis: 5,
part: 0,
});
}
skin.jobs.push(SkinJob {
first: i as u32 * 4,
dst: i as u32 * 4,
count: 4,
rect: [0., 0., 1., 1.],
color: u32::MAX,
light: 0xf0f0,
overlay: if i == 0 { 1 } else { 5 },
..Default::default()
});
}
let cpu = client::gfx::entity_skin::expand(&skin.new_model, &skin);
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.camera = glam::DVec3::ZERO;
frame.eye = glam::DVec3::ZERO;
frame.pitch = 0.;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("cpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
let matches = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [77, 77, 0])
.count();
assert!(
matches > 100,
"original entity-ID/hurt output, got {matches} pixels"
);
let white = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [88, 192, 0])
.count();
assert!(
white > 100,
"original entity-ID/white-flash output, got {white} pixels"
);
assert_eq!(
expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] == 99)
.count(),
0,
"hidden caster is not camera-visible"
);
assert!(
renderer.skins_entities(),
"compiled standard-pack stages must accept GPU posing"
);
frame.entities = &[];
frame.entity_spans = &[];
frame.skin = Some(&skin);
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("gpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
assert_eq!(
read(&out.join("gpu.png")),
expected,
"CPU and GPU standard-pack pixels"
);
NEEDS_SPANS.store(true, Ordering::Relaxed);
assert!(
!renderer.skins_entities(),
"native CPU-stream observer keeps its fallback"
);
frame.skin = None;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.render(&frame)?;
assert_eq!(OBSERVED.load(Ordering::Relaxed), 1);
NEEDS_SPANS.store(false, Ordering::Relaxed);
assert!(renderer.skins_entities());
pack(&mut renderer, &out.join("failed-pack"), false);
assert!(
!renderer.skins_entities(),
"failed entity programs retain CPU posing"
);
renderer.drop_shader_packs();
assert!(
renderer.skins_entities(),
"ordinary path returns when the pack is dropped"
);
assert_eq!(renderer.skins_tagged(), unmodified_tagged);
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"cpu_gpu_pixels_equal": true, "entity_id_hurt_pixels": matches, "hidden_subject_pixels": 0,
"entity_id_white_flash_pixels": white,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
"native_observer_frames": OBSERVED.load(Ordering::Relaxed), "player_game_started": false,
"fixture_only_not_gameplay_or_fps_proof": true
}))?,
)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(0.5, 100.0, 0.5),
eye_rotation: [0.0, 10.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+53 -8
View File
@@ -105,7 +105,14 @@ pub struct RenderAssets {
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -117,8 +124,7 @@ impl Assets {
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) =
BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let mut definitions = TagDefinitions::default();
@@ -201,6 +207,20 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] with the model library already parsed.
///
/// Parsing it reads every model file under `assets/`, which needs no
/// block registry, so a caller that has the files to spare can start
/// it before `Assets::load` finishes and hand the result in here. On
/// a cold cache that is most of a second of reading moved off the
/// critical path.
pub fn reload_with_models(
&self,
root: &Path,
parsed: crate::model_library::ParsedModels,
) -> Result<RenderAssets> {
RenderAssets::load_with_parsed(root, self, true, Some(parsed))
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
@@ -212,6 +232,16 @@ impl RenderAssets {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
Self::load_with_parsed(root, assets, draws, None)
}
/// `parsed`: the model library when a caller parsed it ahead of this
/// (see [`Assets::reload_with_models`]); otherwise it is parsed here.
fn load_with_parsed(
root: &Path,
assets: &Assets,
draws: bool,
parsed: Option<crate::model_library::ParsedModels>,
) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -219,17 +249,24 @@ impl RenderAssets {
}
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
let parsed = match parsed {
Some(parsed) => parsed,
None if draws => {
let _step = crate::spans::startup::step("core.models_parse");
crate::model_library::ParsedModels::load(root)
}
None => crate::model_library::ParsedModels::default(),
};
let models = if draws {
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
let model_library = {
let _step = crate::spans::startup::step("core.item_library");
parsed.into_item_library()
};
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -244,6 +281,12 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
};
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
@@ -304,6 +347,8 @@ impl RenderAssets {
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
fusion,
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
})
}
}
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
@@ -369,14 +379,17 @@ pub fn generate(
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
let key = argb(&key);
let target = argb(&target);
apply_palette(&mut mapped, &key, &target)?;
let out: Vec<u8> = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
drop(mapped);
Some((w, h, out))
}
}
+26 -12
View File
@@ -61,6 +61,8 @@ struct Mixer {
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -183,6 +185,7 @@ struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
looping: bool,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
@@ -420,7 +423,7 @@ impl Backend for MixerBackend {
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
attach(&self.mixer, channel, samples, looping);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
@@ -439,13 +442,18 @@ impl Backend for MixerBackend {
}
samples
});
attach(&mixer, job.channel, samples, false);
attach(&mixer, job.channel, samples, job.looping);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
let _ = decoder.send(Decode {
key,
file,
channel,
looping,
});
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
@@ -538,9 +546,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
mixer.cameras.clear();
mixer.cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
@@ -559,10 +566,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
)
}),
);
let shared = cameras.len() > 1;
let shared = mixer.cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -580,7 +588,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
@@ -611,8 +620,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -642,6 +650,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
mixer.voices = voices;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -1206,8 +1215,13 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.engine.registry = registry;
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+178 -17
View File
@@ -101,6 +101,22 @@ impl AuthSession {
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
/// says.
pub fn expired(&self) -> bool {
let exp = self
.access_token
.split('.')
.nth(1)
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
.and_then(|v| v.get("exp")?.as_u64());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
exp.is_some_and(|exp| exp <= now + 60)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
@@ -187,6 +203,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -253,9 +270,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -264,11 +305,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
}
}
@@ -403,11 +452,8 @@ struct ChatKeys {
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
@@ -416,7 +462,14 @@ pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
prepare_chat_keys_for(session, now);
}
/// Prepare keys for this player, independently of the other local accounts.
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
let uuid = session.uuid;
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
@@ -430,12 +483,13 @@ pub fn prepare_chat_keys(now: bool) {
return;
}
keys.fetching = true;
drop(keys);
drop(accounts);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
@@ -443,19 +497,24 @@ pub fn prepare_chat_keys(now: bool) {
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
chat_keys_for(current()?.uuid)
}
/// This account's key pair; another local player's keys can never be returned.
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
CHAT_KEYS
.lock()
.unwrap()
.get(&uuid)?
.current
.clone()
.filter(|k| !k.public.has_expired(now))
.filter(|k| !k.public.has_expired(now_millis()))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
@@ -564,6 +623,77 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
/// base64url without padding, as a JWT's parts are.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n =
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
for k in 0..=chunk.len() {
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
}
}
out
}
#[test]
fn a_token_past_its_exp_is_expired() {
// header.payload.signature, the payload base64url without padding.
let token = |exp: u64| {
let json = format!(r#"{{"exp":{exp}}}"#);
let payload = base64url(json.as_bytes());
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(token(now - 10).expired());
assert!(
token(now + 30).expired(),
"a minute from its end: as good as gone"
);
assert!(!token(now + 3600).expired());
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -727,6 +857,37 @@ mod tests {
.to_string()
}
#[test]
fn local_accounts_use_their_own_chat_keys() {
let pair = |signature: &[u8]| {
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
keys.public.expires_at = i64::MAX;
keys.refreshed_after = i64::MAX;
keys.public.signature = signature.to_vec();
Arc::new(keys)
};
let ann = pair(b"ann");
let bea = pair(b"bea");
{
let mut accounts = CHAT_KEYS.lock().unwrap();
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
accounts.insert(
uuid,
ChatKeys {
current: Some(keys),
..Default::default()
},
);
}
}
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
assert!(chat_keys_for(103).is_none());
CHAT_KEYS.lock().unwrap().remove(&101);
CHAT_KEYS.lock().unwrap().remove(&102);
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
+37 -16
View File
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
&path,
)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -277,21 +278,22 @@ impl Biomes {
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
// from the prepared data file when there is one.
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
let mut files: Vec<_> = folder
.files
.iter()
.filter(|f| !f.name.contains('/'))
.collect();
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
&maps,
));
}
let index = list
.iter()
@@ -432,6 +434,25 @@ impl Biomes {
}
changed.then_some(out)
}
/// This table (the biome files') with its colours passed through `f`, when
/// they differ from `current`'s: what a refresh of a table already
/// coloured by `f` once must not compound.
pub fn recolored_from(
&self,
current: &Self,
f: impl FnMut(&str, &mut BiomeColors),
) -> Option<Self> {
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
let same = out.list.len() == current.list.len()
&& out
.list
.iter()
.zip(&current.list)
.all(|(a, b)| tints(a) == tints(b));
(!same).then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
@@ -46,6 +46,7 @@ pub enum Ticking {
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
@@ -84,6 +85,18 @@ pub struct BlockEntities {
/// which leaves out a mod block whose template has none. One taken out
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
/// The positions of the block entities that draw a bounding box (structure and test
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
fn shows_box(entity: &BlockEntity) -> bool {
matches!(
entity,
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
)
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
@@ -220,11 +233,53 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The structure and test instance blocks (a frame draws their boxes), in no order.
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
self.boxes
.iter()
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
}
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
if entity.is_some_and(shows_box) {
self.boxes.insert(pos);
} else {
self.boxes.remove(&pos);
}
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
@@ -237,6 +292,10 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -248,12 +307,28 @@ impl BlockEntities {
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_box {
self.boxes.insert(pos);
} else if old.as_ref().is_some_and(shows_box) {
self.boxes.remove(&pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
@@ -275,15 +350,20 @@ impl BlockEntities {
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if absent && shows_box(entity) {
self.boxes.insert(pos);
}
entity
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
@@ -301,6 +381,7 @@ impl BlockEntities {
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -311,21 +392,30 @@ impl BlockEntities {
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let mods = &mut self.mods;
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
}
kept
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -342,7 +432,9 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
@@ -476,6 +568,53 @@ mod tests {
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
@@ -560,4 +699,43 @@ mod tests {
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
#[test]
fn boxes_list_exactly_the_structure_and_test_blocks() {
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
let listed = |b: &BlockEntities| {
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
v.sort();
v
};
let mut b = level();
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
b.insert(p, test_block());
b.insert(q, hopper());
assert_eq!(listed(&b), [p]);
// Replaced by another kind, and back.
b.insert(p, hopper());
assert_eq!(listed(&b), []);
b.insert(q, test_block());
b.get_or_insert_with(r, test_block);
assert_eq!(listed(&b), [q, r]);
// Taken out: not shown until put back; put back as another kind: gone.
let taken = b.take(&q).unwrap();
assert_eq!(listed(&b), [r]);
b.put(q, taken);
assert_eq!(listed(&b), [q, r]);
let taken = b.take(&r).unwrap();
drop(taken);
b.put(r, hopper());
assert_eq!(listed(&b), [q]);
b.remove(&q);
assert_eq!(listed(&b), []);
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
assert_eq!(listed(&b), [p]);
b.retain(|pos, _| *pos != p);
assert_eq!(listed(&b), []);
b.insert(r, test_block());
b.clear();
assert!(b.boxes.is_empty());
}
}
@@ -383,7 +383,7 @@ impl Shulker {
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
/// (`level.noCollision` of the lid box at half progress, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
@@ -0,0 +1,630 @@
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
//! atlas has it.
//!
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
//! block models here, so their files are not read; copper golem statues have no CEM name.
use crate::block_entity_render::ModelPart;
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
/// names for its vanilla parts.
struct Target {
key: &'static str,
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
/// apart shares (a trapped chest's is the chest's).
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
}
const CHEST: &[(&str, &str)] = &[
("root", "root"),
("base", "bottom"),
("lid", "lid"),
("knob", "lock"),
];
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
const PIGLIN: &[(&str, &str)] = &[
("root", "root"),
("head", "head"),
("left_ear", "left_ear"),
("right_ear", "right_ear"),
];
const BELL: &[(&str, &str)] = &[
("root", "root"),
("body", "bell_body"),
("base", "bell_base"),
];
const BOOK: &[(&str, &str)] = &[
("root", "root"),
("cover_right", "right_lid"),
("cover_left", "left_lid"),
("pages_right", "right_pages"),
("pages_left", "left_pages"),
("flipping_page_right", "flip_page1"),
("flipping_page_left", "flip_page2"),
("book_spine", "seam"),
];
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
const POT_SIDES: &[(&str, &str)] = &[
("front", "front"),
("back", "back"),
("left", "left"),
("right", "right"),
];
const fn model(
key: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base: key,
names,
parts,
}
}
const fn copy(
key: &'static str,
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base,
names,
parts,
}
}
/// In order: a copy comes after its base.
const TARGETS: &[Target] = &[
model("chest", &["chest"], CHEST),
model("double_chest_left", &["chest_left"], CHEST),
model("double_chest_right", &["chest_right"], CHEST),
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
copy(
"trapped_chest_left",
"double_chest_left",
&["trapped_chest_left"],
CHEST,
),
copy(
"trapped_chest_right",
"double_chest_right",
&["trapped_chest_right"],
CHEST,
),
copy("ender_chest", "chest", &["ender_chest"], CHEST),
model("shulker_box", &["shulker_box"], SHULKER),
model("standing_banner", &["banner_base", "banner"], BANNER),
model("wall_banner", &["banner_base", "banner"], BANNER),
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
model("skeleton_skull", &["head_skeleton"], HEAD),
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
model("zombie_head", &["head_zombie"], HEAD),
model("creeper_head", &["head_creeper"], HEAD),
model("dragon_skull", &["head_dragon"], DRAGON),
model("piglin_head", &["head_piglin"], PIGLIN),
model("player_head", &["head_player"], HEAD),
model("bell", &["bell"], BELL),
model("book", &["enchanting_book", "book"], BOOK),
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
model("conduit_shell", &["conduit"], &[("base", "shell")]),
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
];
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
pub type Reshaped = (
HashMap<&'static str, ModelPart>,
HashMap<&'static str, String>,
);
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
/// a `.jem` there.
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
let (reshaped, unused) = reshape_all(root, vanilla)?;
for (path, part) in unused {
log::warn!("Horizon: custom entity model {path}: no part {part}");
}
Some(reshaped)
}
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
fn reshape_all(
root: &Path,
vanilla: &HashMap<&'static str, ModelPart>,
) -> Option<(Reshaped, Vec<(String, String)>)> {
let files = jem_files(root);
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
if !TARGETS.iter().any(named) {
return None;
}
let mut models = vanilla.clone();
let mut textures = HashMap::new();
let mut reshaped = HashSet::new();
// Each file parsed once; which of its entries found a part in some model.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
for t in TARGETS {
let Some(model) = vanilla.get(t.base) else {
continue;
};
let found = t
.names
.iter()
.flat_map(|name| files.get(*name).into_iter().flatten())
.map(String::as_str)
.find(|&path| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some(path) = found else {
// A copy of a reshaped base that has no file of its own stays vanilla.
if t.key != t.base && reshaped.contains(t.base) {
models.insert(t.key, model.clone());
}
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut out = model.clone();
let hits = apply(&mut out, jem, t.parts);
// A file shared by several models (the conduit's, the pot's) paints only the models
// it reshapes: vanilla geometry keeps its own texture's layout.
let reshapes = hits.iter().any(|hit| *hit);
let marks = used
.entry(path)
.or_insert_with(|| vec![false; jem.parts.len()]);
for (mark, hit) in marks.iter_mut().zip(hits) {
*mark |= hit;
}
models.insert(t.key, out);
reshaped.insert(t.key);
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
textures.insert(t.key, texture);
}
}
let mut unused = Vec::new();
for (path, marks) in &used {
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
unused.push(((*path).to_owned(), p.part.clone()));
}
}
Some(((models, textures), unused))
}
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
/// `cem#<n>`. Which entries found their part.
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
let mut count = 0;
let mut hits = Vec::with_capacity(jem.parts.len());
for p in &jem.parts {
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
let target = match mapped {
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
_ => p.part.as_str(),
};
let Some(node) = find_mut(model, target) else {
hits.push(false);
continue;
};
if !p.attach {
node.polygons.clear();
}
let (name, mut bone) = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
node.children.push((name, bone));
hits.push(true);
}
hits
}
/// The model itself for `root`, else its first part named `name`, depth first.
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
part.children.iter_mut().find_map(|(n, c)| {
if n.as_str() == name {
Some(c)
} else {
child(c, name)
}
})
}
if name == "root" {
return Some(model);
}
child(model, name)
}
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
/// change by name reaches it.
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
let out = ModelPart {
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
};
(name, out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::{collections::BTreeSet, fs};
/// A part with one face and `children`.
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[[0.0; 5]; 4]],
children: children
.into_iter()
.map(|(n, c)| (n.to_owned(), c))
.collect(),
}
}
pub(crate) fn chest() -> ModelPart {
let mut root = plane(vec![
("bottom", plane(vec![])),
("lid", plane(vec![])),
("lock", plane(vec![])),
]);
root.polygons.clear();
root
}
pub(crate) const BOX: &str =
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// A `.jem` replacing each of `parts` with one box.
fn jem(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
/// deep, so a part that took another entry's box shows.
fn jem_sized(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.enumerate()
.map(|(i, p)| {
let depth = i + 1;
format!(
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
)
})
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// How deep (z) the custom box under `model`'s part `name` is.
fn depth(model: &ModelPart, name: &str) -> f32 {
let part = model.child(name).unwrap();
let zs = part
.children
.iter()
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
hi - lo
}
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
/// child of a box's six faces.
fn replaced(model: &ModelPart, name: &str) -> bool {
let part = model.child(name).unwrap();
match part.children.as_slice() {
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
_ => false,
}
}
#[test]
fn every_optifine_part_name_lands_on_its_vanilla_part() {
// File, OptiFine's part, the model it reshapes, vanilla's part there.
let cases = [
("chest", "base", "chest", "bottom"),
("chest", "lid", "chest", "lid"),
("chest", "knob", "chest", "lock"),
("chest_left", "base", "double_chest_left", "bottom"),
("chest_left", "lid", "double_chest_left", "lid"),
("chest_left", "knob", "double_chest_left", "lock"),
("chest_right", "base", "double_chest_right", "bottom"),
("chest_right", "lid", "double_chest_right", "lid"),
("chest_right", "knob", "double_chest_right", "lock"),
("trapped_chest", "base", "trapped_chest", "bottom"),
("trapped_chest", "knob", "trapped_chest", "lock"),
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
(
"trapped_chest_right",
"base",
"trapped_chest_right",
"bottom",
),
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
("ender_chest", "base", "ender_chest", "bottom"),
("ender_chest", "knob", "ender_chest", "lock"),
("shulker_box", "base", "shulker_box", "base"),
("shulker_box", "lid", "shulker_box", "lid"),
("banner", "stand", "standing_banner", "pole"),
("banner", "top", "standing_banner", "bar"),
("banner", "top", "wall_banner", "bar"),
("banner", "slate", "standing_banner_flag", "flag"),
("banner", "slate", "wall_banner_flag", "flag"),
("head_skeleton", "head", "skeleton_skull", "head"),
(
"head_wither_skeleton",
"head",
"wither_skeleton_skull",
"head",
),
("head_zombie", "head", "zombie_head", "head"),
("head_creeper", "head", "creeper_head", "head"),
("head_dragon", "head", "dragon_skull", "head"),
("head_dragon", "jaw", "dragon_skull", "jaw"),
("head_piglin", "head", "piglin_head", "head"),
("head_piglin", "left_ear", "piglin_head", "left_ear"),
("head_piglin", "right_ear", "piglin_head", "right_ear"),
("head_player", "head", "player_head", "head"),
("bell", "body", "bell", "bell_body"),
("bell", "base", "bell", "bell_base"),
("enchanting_book", "cover_right", "book", "right_lid"),
("enchanting_book", "cover_left", "book", "left_lid"),
("enchanting_book", "pages_right", "book", "right_pages"),
("enchanting_book", "pages_left", "book", "left_pages"),
(
"enchanting_book",
"flipping_page_right",
"book",
"flip_page1",
),
(
"enchanting_book",
"flipping_page_left",
"book",
"flip_page2",
),
("enchanting_book", "book_spine", "book", "seam"),
("lectern_book", "cover_right", "lectern_book", "right_lid"),
("lectern_book", "pages_left", "lectern_book", "left_pages"),
(
"lectern_book",
"flipping_page_right",
"lectern_book",
"flip_page1",
),
("lectern_book", "book_spine", "lectern_book", "seam"),
("conduit", "base", "conduit_shell", "shell"),
("conduit", "cage", "conduit_cage", "shell"),
("conduit", "wind", "conduit_wind", "wind"),
("conduit", "eye", "conduit_eye", "eye"),
("decorated_pot", "neck", "decorated_pot_base", "neck"),
("decorated_pot", "top", "decorated_pot_base", "top"),
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
("decorated_pot", "front", "decorated_pot_sides", "front"),
("decorated_pot", "back", "decorated_pot_sides", "back"),
("decorated_pot", "left", "decorated_pot_sides", "left"),
("decorated_pot", "right", "decorated_pot_sides", "right"),
];
// One file per name with each of its parts, and a stub of each model holding its parts
// (chest models all of theirs, so a misplaced entry shows).
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (file, part, key, vanilla) in cases {
files.entry(file).or_default().insert(part);
trees.entry(key).or_default().insert(vanilla);
}
for key in ["chest", "double_chest_left", "double_chest_right"] {
trees
.entry(key)
.or_default()
.extend(["bottom", "lid", "lock"]);
}
let files_by_name = files.clone();
let files: Vec<_> = files
.iter()
.map(|(file, parts)| {
let parts: Vec<_> = parts.iter().copied().collect();
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
})
.collect();
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
let root = pack("parts", &files);
let vanilla: HashMap<&'static str, ModelPart> = trees
.iter()
.map(|(key, parts)| {
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
(*key, plane(parts))
})
.collect();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, _), unused) = result.unwrap();
for (file, part, key, vanilla) in cases {
assert!(
replaced(&models[key], vanilla),
"{file}.jem {part}: {key} {vanilla}"
);
// The box is this entry's own: swapped names (left and right, body and base) show.
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
assert_eq!(
depth(&models[key], vanilla),
own as f32,
"{file}.jem {part}: {key} {vanilla}"
);
}
assert_eq!(unused, Vec::new());
}
#[test]
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
let root = pack(
"chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
{{"part": "lid", {BOX}}},
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
{{"part": "bottom", {BOX}}},
{{"part": "tail", {BOX}}}]}}"#
),
)],
);
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
let chest = &models["chest"];
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
assert!(replaced(chest, "lid"));
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
assert!(replaced(chest, "bottom"));
// Attached: the lock keeps its face beside the scaled box.
let lock = chest.child("lock").unwrap();
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
// `tail` names no chest part: the one warning.
assert_eq!(
unused,
[(
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
"tail".to_owned()
)]
);
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
// only the chest takes the texture.
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
let lid = models[key].child("lid").unwrap();
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
}
assert_eq!(
textures,
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
);
}
#[test]
fn banners_split_one_file_and_books_fall_back() {
let root = pack(
"banner",
&[
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
],
);
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
let wall = plane(vec![("bar", plane(vec![]))]);
let flag = plane(vec![("flag", plane(vec![]))]);
let book = plane(vec![("left_lid", plane(vec![]))]);
let vanilla: HashMap<_, _> = [
("standing_banner", banner),
("wall_banner", wall),
("wall_banner_flag", flag),
("book", book),
]
.into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
assert!(replaced(&models["standing_banner"], "pole"));
assert!(replaced(&models["standing_banner"], "bar"));
assert!(replaced(&models["wall_banner"], "bar"));
assert!(replaced(&models["wall_banner_flag"], "flag"));
assert!(replaced(&models["book"], "left_lid"));
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
let lectern = models["lectern_book"].child("left_lid").unwrap();
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
assert!(textures.is_empty());
// Each entry found its part in one of the models: no warning.
assert_eq!(unused, Vec::new());
}
#[test]
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, _) = result.unwrap();
assert!(replaced(&models["book"], "left_lid"));
assert!(replaced(&models["lectern_book"], "left_lid"));
}
#[test]
fn a_shared_file_paints_only_the_models_it_reshapes() {
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
// vanilla geometry and so their own textures.
let file = format!(
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
);
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
let vanilla: HashMap<_, _> = [
"conduit_shell",
"conduit_cage",
"conduit_wind",
"conduit_eye",
]
.map(|key| {
let part = if key == "conduit_eye" { "eye" } else { "shell" };
(key, plane(vec![(part, plane(vec![]))]))
})
.into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, textures) = result.unwrap();
assert!(replaced(&models["conduit_eye"], "eye"));
let painted: BTreeSet<_> = textures.keys().copied().collect();
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,432 @@
//! Where a block's textures are in the block sprite arrays, side by side, for drawing the block
//! somewhere the mesher does not: a native mod's own pass drawing blocks as voxels
//! (`native_block_sprites`, `WorldDrawn::block_sprites`; docs/native-mods.md). The answer is the
//! model's own faces, as the mesher bakes them: each face's sprite (its array and layer), where on
//! the block's side it lies and how its texture runs across it, and its tint source.
//!
//! Each sprite is a whole layer of a square texture array (`sprites::Sprites`), so a face's UVs run
//! 0 to 1 within its layer; the renderer's split (`Renderer::sprite_split`) layers to an array.
use crate::{mesher::TintSource, models::catalog::BakedFace, streaming::WorkerContext};
use horizon_assets::model::Direction;
use horizon_core::position::BlockPos;
use horizon_world::registry::{BlockRegistry, BlockStateId};
/// A sprite: its array (0 for every vanilla texture on most GPUs; past the first array's layers in
/// 1 to 7) and its layer in that array.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpriteRef {
pub array: u32,
pub layer: u32,
/// It animates (water, lava, fire, sea lanterns...): the arrays always hold the frame being shown.
pub animated: bool,
/// Some of its pixels are clear (alpha 0): leaves, glass, flowers. Draw those as holes.
pub cutout: bool,
}
/// One face of a block's model on one of its sides.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceSprite {
pub sprite: SpriteRef,
/// Where the face lies on the side, in the side's two axes ([`side_axes`]), from 0 to 1 across the block:
/// `[a0, b0, a1, b1]`. `[0, 0, 1, 1]` for a whole face.
pub rect: [f32; 4],
/// How deep into the block the face lies from the side's own plane (0 for a cube's face, 0.5 for a slab's top).
pub inset: f32,
/// The texture across the side: the point (a, b) on it shows `uv = [u0, v0] + a * [du_u, du_v] + b * [dv_u,
/// dv_v]` of the sprite, `[u0, v0, du_u, du_v, dv_u, dv_v]`. Rotations and mirrors (a log on its side, a
/// model's `rotation` and `uvlock`) are in it.
pub uv: [f32; 6],
/// Its colour source, none for an untinted face: multiply the texture by the source's colour.
pub tint: Option<TintSource>,
/// It has partly clear pixels: drawn sorted and blended.
pub translucent: bool,
}
/// What a block draws on each side, in [`Direction::ALL`]'s order (down, up, north, south, west, east).
#[derive(Clone, Debug, PartialEq)]
pub struct BlockSprites {
/// The faces on each side, in the model's order: the one drawn first first (a grass block's side: its dirt
/// and grass, then the grass overlay tinted over it). A side with none is empty. Faces that do not lie flat
/// on a side (a flower's crossed planes, a rotated element) are left out.
pub sides: [Vec<FaceSprite>; 6],
/// The model's particle sprite (what breaking it scatters, a flower's whole picture), if it has one.
pub particle: Option<SpriteRef>,
/// The model is a plain cube: one whole face on each side, at its plane.
pub cube: bool,
}
/// The two axes a side's `rect` and `uv` count along, as indices into (x, y, z): down and up across x then z; north
/// and south across x then y; west and east across z then y. (The block's corner at 0, 0, 0 is 0 on each.)
pub fn side_axes(side: Direction) -> (usize, usize) {
match side {
Direction::Down | Direction::Up => (0, 2),
Direction::North | Direction::South => (0, 1),
Direction::West | Direction::East => (2, 1),
}
}
/// The axis a side faces along, and where its plane is (0 or 1).
fn side_plane(side: Direction) -> (usize, f32) {
match side {
Direction::Down => (1, 0.0),
Direction::Up => (1, 1.0),
Direction::North => (2, 0.0),
Direction::South => (2, 1.0),
Direction::West => (0, 0.0),
Direction::East => (0, 1.0),
}
}
/// A block state from `name[property=value,...]`, the namespace `minecraft:` when it has none; properties left
/// out take the block's default. None for a block or a property the game does not know.
pub fn parse_state(registry: &BlockRegistry, text: &str) -> Option<BlockStateId> {
let (name, props) = match text.trim().split_once('[') {
Some((n, rest)) => (n, rest.strip_suffix(']')?),
None => (text.trim(), ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry.default_state(&name)?;
let mut state = registry.state(default)?.clone();
for pair in props.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=')?;
let slot = state.properties.get_mut(k.trim())?;
*slot = v.trim().to_owned();
}
registry.resolve(&state).ok()
}
/// The sprites `context`'s resources hold, `split` layers to an array (`Renderer::sprite_split`).
pub struct Lookup<'a> {
context: &'a WorkerContext,
split: u32,
}
impl<'a> Lookup<'a> {
pub fn new(context: &'a WorkerContext, split: u32) -> Self {
Self {
context,
split: split.max(1),
}
}
/// The sprite at `layer` of all the layers.
pub fn sprite(&self, layer: u32) -> SpriteRef {
let render = &self.context.render;
SpriteRef {
array: layer / self.split,
layer: layer % self.split,
animated: render.animations.iter().any(|a| a.layer == layer),
cutout: render
.sprite_mips
.get(layer as usize)
.is_some_and(|m| m.has_transparent),
}
}
/// A texture by name (`minecraft:block/stone`, `block/stone`); none for one no model uses (the arrays hold only
/// the textures models and the game's own drawing name).
pub fn texture(&self, name: &str) -> Option<SpriteRef> {
let layers = &self.context.render.sprite_layers;
let layer = layers
.get(name)
.or_else(|| layers.get(&format!("minecraft:{name}")))?;
Some(self.sprite(*layer))
}
/// What block `name` (`minecraft:oak_log[axis=x]`; properties left out take the default) draws on each side;
/// none for a block the game does not know.
pub fn block(&self, name: &str) -> Option<BlockSprites> {
let state = parse_state(self.context.assets.registry, name)?;
Some(self.state(state))
}
/// What `state` draws on each side. A fluid has its still texture on all six; a block drawn only as a block
/// entity (a chest, a sign) or not at all (air) has empty sides, and its particle sprite if any.
pub fn state(&self, state: BlockStateId) -> BlockSprites {
let render = &self.context.render;
let catalog = &render.model_catalog;
let mut sides: [Vec<FaceSprite>; 6] = Default::default();
let block = self
.context
.assets
.registry
.state(state)
.map_or("", |s| s.name.as_str());
let fluid = match block {
"minecraft:water" | "minecraft:bubble_column" => {
Some(("block/water_still", Some(TintSource::Water), true))
}
"minecraft:lava" => Some(("block/lava_still", None, false)),
_ => None,
};
if let Some((texture, tint, translucent)) = fluid {
let sprite = self.texture(texture);
if let Some(sprite) = sprite {
for side in &mut sides {
side.push(FaceSprite {
sprite,
rect: [0.0, 0.0, 1.0, 1.0],
inset: 0.0,
uv: [0.0, 0.0, 1.0, 0.0, 0.0, 1.0],
tint,
translucent,
});
}
}
return BlockSprites {
sides,
particle: sprite,
cube: sprite.is_some(),
};
}
let models = catalog
.collect(state, BlockPos::new(0, 0, 0))
.unwrap_or_default();
let mut all = 0;
for face in models.iter().flat_map(|(_, m)| &m.faces) {
all += 1;
let Some((side, sprite)) = self.face(state, face) else {
continue;
};
sides[side as usize].push(sprite);
}
let particle = catalog
.particle(state)
.or_else(|| {
models
.iter()
.flat_map(|(_, m)| &m.faces)
.map(|f| f.layer)
.next()
})
.map(|layer| self.sprite(layer));
// a cube: every side has a whole face at its plane (and perhaps overlays laid on it), and the model has no
// other faces
let whole = |f: &FaceSprite| f.rect == [0.0, 0.0, 1.0, 1.0] && f.inset == 0.0;
let cube = all == sides.iter().map(Vec::len).sum::<usize>()
&& sides.iter().all(|s| !s.is_empty() && s.iter().all(whole));
BlockSprites {
sides,
particle,
cube,
}
}
/// A model face as it lies on its side; none when it does not lie flat on one.
fn face(&self, state: BlockStateId, face: &BakedFace) -> Option<(Direction, FaceSprite)> {
let q = &face.quad;
let side = q.facing;
let (normal, plane) = side_plane(side);
let depth = q.positions[0][normal];
if q.positions.iter().any(|p| (p[normal] - depth).abs() > 1e-4) {
return None;
}
let (a, b) = side_axes(side);
let ab: [[f32; 2]; 4] = q.positions.map(|p| [p[a], p[b]]);
let uv = affine(&ab, &q.uv)?;
let (mut a0, mut b0, mut a1, mut b1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for [x, y] in ab {
a0 = a0.min(x);
b0 = b0.min(y);
a1 = a1.max(x);
b1 = b1.max(y);
}
let snap = |v: f32| {
let r = (v * 4096.0).round() / 4096.0;
if r == 0.0 { 0.0 } else { r }
};
Some((
side,
FaceSprite {
sprite: self.sprite(face.layer),
rect: [a0, b0, a1, b1].map(snap),
inset: snap((depth - plane).abs()),
uv: uv.map(snap),
tint: self.context.tables.tint_source(state, q.tint_index),
translucent: face.translucent,
},
))
}
}
/// The affine map from a face's points on its side to its UVs, `[u0, v0, du_u, du_v, dv_u, dv_v]`, from three of
/// its corners that are not in a line (a model's faces are parallelograms on their side).
fn affine(ab: &[[f32; 2]; 4], uv: &[[f32; 2]; 4]) -> Option<[f32; 6]> {
for [i, j, k] in [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]] {
let d1 = [ab[j][0] - ab[i][0], ab[j][1] - ab[i][1]];
let d2 = [ab[k][0] - ab[i][0], ab[k][1] - ab[i][1]];
let det = d1[0] * d2[1] - d1[1] * d2[0];
if det.abs() < 1e-6 {
continue;
}
let e1 = [uv[j][0] - uv[i][0], uv[j][1] - uv[i][1]];
let e2 = [uv[k][0] - uv[i][0], uv[k][1] - uv[i][1]];
// e1 = d1.a * du + d1.b * dv, e2 = d2.a * du + d2.b * dv
let du = [
(e1[0] * d2[1] - e2[0] * d1[1]) / det,
(e1[1] * d2[1] - e2[1] * d1[1]) / det,
];
let dv = [
(e2[0] * d1[0] - e1[0] * d2[0]) / det,
(e2[1] * d1[0] - e1[1] * d2[0]) / det,
];
let o = [
uv[i][0] - ab[i][0] * du[0] - ab[i][1] * dv[0],
uv[i][1] - ab[i][0] * du[1] - ab[i][1] * dv[1],
];
return Some([o[0], o[1], du[0], du[1], dv[0], dv[1]]);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_faces_texture_runs_across_its_side() {
// a whole face, the texture upright: u along a, v along b
let ab = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert_eq!(
affine(&ab, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
Some([0.0, 0.0, 1.0, 0.0, 0.0, 1.0])
);
// turned a quarter (a log on its side): u runs down b, v back along a
let turned = affine(&ab, &[[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]).unwrap();
assert_eq!(turned, [0.0, 1.0, 0.0, -1.0, 1.0, 0.0]);
// a slab's side: the lower half of the block, the lower half of the texture (v counts down)
let half = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]];
let m = affine(&half, &[[0.0, 1.0], [1.0, 1.0], [1.0, 0.5], [0.0, 0.5]]).unwrap();
assert_eq!(m, [0.0, 1.0, 1.0, 0.0, 0.0, -1.0]);
// a face seen edge on has no map
assert_eq!(affine(&[[0.0, 0.0]; 4], &[[0.0, 0.0]; 4]), None);
}
/// The game's own blocks from real prepared resources: a cube, a grass block's tinted top and overlaid sides, a
/// log on its side (its texture turned), leaves (cut out, foliage), water, a slab and a flower.
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_blocks_name_their_sprites() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, blocks::BlockFacts, light::LightTables,
mesher::MeshTables,
};
use std::sync::Arc;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let tables = Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?);
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables,
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let lookup = Lookup::new(&context, 2048);
let whole = [0.0, 0.0, 1.0, 1.0];
let upright = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
let stone = lookup.block("minecraft:stone").unwrap();
assert!(stone.cube);
let top = stone.sides[Direction::Up as usize][0];
assert_eq!(
(top.rect, top.inset, top.tint, top.sprite.array),
(whole, 0.0, None, 0)
);
assert_eq!(lookup.texture("minecraft:block/stone"), Some(top.sprite));
assert_eq!(lookup.texture("block/stone"), Some(top.sprite));
assert!(!top.sprite.animated && !top.sprite.cutout);
let grass = lookup.block("grass_block").unwrap();
assert!(grass.cube);
assert_eq!(
grass.sides[Direction::Up as usize][0].tint,
Some(TintSource::Grass)
);
assert_eq!(grass.sides[Direction::Down as usize][0].tint, None);
let side = &grass.sides[Direction::North as usize];
assert_eq!(side.len(), 2, "the side and its overlay");
assert_eq!(
(side[0].tint, side[1].tint),
(None, Some(TintSource::Grass))
);
assert_eq!(
lookup.texture("block/grass_block_side_overlay"),
Some(side[1].sprite)
);
// seen from outside, a side's texture runs from its top left: on the north side (looking south, east on
// the left) u falls with x and v with y; on top, u and v run with x and z (stone's first variant is turned)
let planks = lookup.block("oak_planks").unwrap();
let uv = |b: &BlockSprites, d: Direction| b.sides[d as usize][0].uv;
let north = uv(&planks, Direction::North);
assert_eq!(north, [1.0, 1.0, -1.0, 0.0, 0.0, -1.0]);
assert_eq!(uv(&planks, Direction::Up), upright);
assert_eq!(
uv(&planks, Direction::South),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
assert_eq!(
uv(&planks, Direction::Down),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
// an upright log's side runs as stone's; on its side (axis x) the texture is turned a quarter
let log = lookup.block("oak_log").unwrap();
let log_x = lookup.block("minecraft:oak_log[axis=x]").unwrap();
assert_eq!(log.sides[Direction::North as usize][0].uv, north);
let turned = log_x.sides[Direction::North as usize][0].uv;
assert_eq!(
(turned[2], turned[5]),
(0.0, 0.0),
"u runs along y, v along x: {turned:?}"
);
assert_eq!(
log_x.sides[Direction::West as usize][0].sprite,
log.sides[Direction::Up as usize][0].sprite,
"its end faces west"
);
let leaves = lookup.block("oak_leaves").unwrap();
let leaf = leaves.sides[Direction::Up as usize][0];
assert!(leaf.sprite.cutout);
assert_eq!(leaf.tint, Some(TintSource::Foliage));
assert!(matches!(
lookup.block("spruce_leaves").unwrap().sides[0][0].tint,
Some(TintSource::Fixed(0x61_9961))
));
let water = lookup.block("water").unwrap();
let w = water.sides[Direction::Up as usize][0];
assert!(w.sprite.animated && w.translucent);
assert_eq!(w.tint, Some(TintSource::Water));
let slab = lookup.block("stone_slab").unwrap();
assert!(!slab.cube);
assert_eq!(slab.sides[Direction::Up as usize][0].inset, 0.5);
assert_eq!(
slab.sides[Direction::North as usize][0].rect,
[0.0, 0.0, 1.0, 0.5]
);
let flower = lookup.block("dandelion").unwrap();
assert!(!flower.cube && flower.sides.iter().all(Vec::is_empty));
assert!(flower.particle.is_some_and(|p| p.cutout));
assert_eq!(lookup.block("minecraft:no_such_block"), None);
assert_eq!(lookup.block("stone[no_such=1]"), None);
assert!(lookup.block("air").unwrap().sides.iter().all(Vec::is_empty));
Ok(())
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
//! Finding a server's BlueMap without the player naming it
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
//! likely web addresses (`candidates`), taking only one whose `settings.json`
//! is BlueMap's web-app settings, and the answer is kept per server in
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
//! A `blueMap:` line the player wrote always wins.
use crate::bluemap::Settings;
use std::{
net::IpAddr,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime},
};
/// The answer kept per server, beside its BlueMap tiles.
pub const FILE: &str = "detected.txt";
/// A server found without a map is asked again after this long.
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
/// A map found is checked again (its address first) after this long.
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
/// Each probe gets this long, connecting included.
pub const TIMEOUT: Duration = Duration::from_secs(4);
/// Probes start this far apart at least.
pub const GAP: Duration = Duration::from_millis(500);
/// Where a level's BlueMap comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// The player's `blueMap:` line, or a map found before.
Url {
url: String,
maps: Vec<(String, String)>,
},
/// To be looked for at these addresses, in order.
Detect(Vec<String>),
}
/// What the server at `address` draws far terrain from, its BlueMap folder
/// being `dir`: `None` when the player named no map and either auto-detect
/// is off or the server was found without one recently.
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
if let Some(server) = settings.server(address) {
return Some(Source::Url {
url: server.url.clone(),
maps: server.maps.clone(),
});
}
if !settings.auto_detect {
return None;
}
let mut probes = Vec::new();
// Only an address this server's would be looked for at: a remembered
// one from elsewhere (a copied folder) names someone else's.
let ours = candidates(address);
match remembered(dir, now) {
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
Remembered::Found(url) => {
return Some(Source::Url {
url,
maps: Vec::new(),
});
}
Remembered::Nothing => return None,
Remembered::Stale(Some(url)) => probes.push(url),
Remembered::Stale(None) => {}
}
for candidate in ours {
if !probes.contains(&candidate) {
probes.push(candidate);
}
}
(!probes.is_empty()).then_some(Source::Detect(probes))
}
/// The first labels a game server's host name takes beside its site's.
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
/// Domains whose names belong to many customers (dynamic DNS, server
/// hosts): `map.` of them is someone else's.
const SHARED: &[&str] = &[
"aternos.me",
"ddns.net",
"duckdns.org",
"hopto.org",
"minehut.gg",
"no-ip.org",
"playit.gg",
"serveminecraft.net",
"zapto.org",
];
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
/// web server (port 8100), the server's host, and its `map.` subdomain, also
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
pub fn candidates(address: &str) -> Vec<String> {
let address = address.trim().to_ascii_lowercase();
let host = if let Some(rest) = address.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if address.matches(':').count() > 1 {
&address[..]
} else {
address.split(':').next().unwrap_or(&address)
};
let host = host.trim_end_matches('.');
if host.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(ip) = host.parse::<IpAddr>() {
// Certificates are for names: plain HTTP only.
let host = match ip {
IpAddr::V6(_) => format!("[{host}]"),
IpAddr::V4(_) => host.to_owned(),
};
out.push(format!("http://{host}:8100"));
out.push(format!("http://{host}"));
return out;
}
if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Vec::new();
}
out.push(format!("http://{host}:8100"));
let labels = host.split('.').count();
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("https://{map}"));
}
out.push(format!("https://{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("http://{map}"));
}
out.push(format!("http://{host}"));
// `play.example.net` → `map.example.net`, only for a game server's
// usual names: the parent of `name.hosting.example` is another party's.
if labels >= 3
&& let Some((first, parent)) = host.split_once('.')
&& GAME_LABELS.contains(&first)
&& !SHARED.contains(&parent)
&& let Some(map) = map(parent)
{
out.push(format!("https://{map}"));
out.push(format!("http://{map}"));
}
let mut seen = Vec::new();
out.retain(|url| {
let new = !seen.contains(url);
seen.push(url.clone());
new
});
out
}
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
/// and where their data lies, not any page a web server returns.
pub fn is_web_app_settings(json: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
return false;
};
let maps = v.get("maps").and_then(|m| m.as_array());
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
}
/// Whether a BlueMap web app answers at `base`.
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
use std::io::Read;
let Ok(response) = agent
.get(&format!("{base}/settings.json"))
.timeout(TIMEOUT)
.call()
else {
return false;
};
let mut body = Vec::new();
response
.into_reader()
.take(256 << 10)
.read_to_end(&mut body)
.is_ok()
&& is_web_app_settings(&body)
}
/// What an earlier look found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Remembered {
Found(String),
/// Nothing, recently.
Nothing,
/// Never looked, or long enough ago to look again (first at the map
/// found then, if any).
Stale(Option<String>),
}
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
return Remembered::Stale(None);
};
let mut words = text.split_whitespace();
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
return Remembered::Stale(None);
};
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
let Ok(age) = now.duration_since(at) else {
return Remembered::Stale(None);
};
match (kind, words.next()) {
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
_ => Remembered::Stale(None),
}
}
/// Keeps what a look found (`None`: no map).
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let text = match found {
Some(url) => format!("found {secs} {url}\n"),
None => format!("none {secs}\n"),
};
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(FILE), text);
}
/// The servers being looked at now, by BlueMap folder: one look a server at
/// a time, however many levels open meanwhile.
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// The right to look for `dir`'s server's map, until dropped.
pub struct Claim(PathBuf);
impl Claim {
/// `None` while another look at the same server runs.
pub fn take(dir: &Path) -> Option<Self> {
let mut looking = LOOKING.lock().unwrap();
if looking.iter().any(|d| d == dir) {
return None;
}
looking.push(dir.to_owned());
Some(Self(dir.to_owned()))
}
}
impl Drop for Claim {
fn drop(&mut self) {
LOOKING.lock().unwrap().retain(|d| *d != self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_cover_the_web_server_host_and_map_subdomains() {
assert_eq!(
candidates("Play.Example.net:25566"),
[
"http://play.example.net:8100",
"https://map.play.example.net",
"https://play.example.net",
"http://map.play.example.net",
"http://play.example.net",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("example.net"),
[
"http://example.net:8100",
"https://map.example.net",
"https://example.net",
"http://map.example.net",
"http://example.net",
]
);
// Already the map's host: no `map.map.`.
assert_eq!(
candidates("map.example.net"),
[
"http://map.example.net:8100",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("192.0.2.7:25565"),
["http://192.0.2.7:8100", "http://192.0.2.7"]
);
assert_eq!(
candidates("[2001:db8::7]:25565"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("2001:db8::7"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("localhost"),
[
"http://localhost:8100",
"https://localhost",
"http://localhost"
]
);
// A hosted name's parent is the host's, not the server's.
assert!(
!candidates("play.ddns.net")
.iter()
.any(|url| url.ends_with("//map.ddns.net"))
);
assert!(
!candidates("name.host.example")
.iter()
.any(|url| url.ends_with("//map.host.example"))
);
assert!(candidates("").is_empty());
assert!(candidates("evil.example.net/path?x").is_empty());
}
#[test]
fn only_blue_maps_settings_count() {
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
assert!(is_web_app_settings(web_app));
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
assert!(
!is_web_app_settings(br#"{"maps":["world"]}"#),
"no data root"
);
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
assert!(!is_web_app_settings(
br#"{"maps":[1],"mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"{"maps":"world","mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
));
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-bluemap-detect-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn answers_are_kept_and_looked_at_again_later() {
let dir = scratch("remember");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let hour = Duration::from_secs(3600);
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
remember(&dir, None, t0);
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
// Written ahead of the clock: not trusted.
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
remember(&dir, Some("https://map.example.net"), t0);
assert_eq!(
remembered(&dir, t0 + 2 * RETRY_NONE),
Remembered::Found("https://map.example.net".into())
);
assert_eq!(
remembered(&dir, t0 + RETRY_FOUND),
Remembered::Stale(Some("https://map.example.net".into()))
);
std::fs::write(dir.join(FILE), "garbage").unwrap();
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_players_map_wins_and_detection_waits_for_the_setting() {
let dir = scratch("source");
let now = SystemTime::now();
let mut settings = Settings::default();
let address = "play.example.net";
assert_eq!(
source(&settings, address, &dir, now),
None,
"off by default"
);
settings.auto_detect = true;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for");
};
assert_eq!(probes, candidates(address));
remember(&dir, None, now);
assert_eq!(
source(&settings, address, &dir, now),
None,
"none, recently"
);
remember(&dir, Some("https://map.example.net"), now);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "https://map.example.net".into(),
maps: Vec::new()
})
);
// A remembered map this server's addresses don't include (a copied
// folder's) is no map.
remember(&dir, Some("https://tracker.example"), now);
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for, the remembered one ignored");
};
assert_eq!(probes, candidates(address));
remember(&dir, Some("https://map.example.net"), now);
// Past a week the found map is tried first, then the rest.
let later = now + RETRY_FOUND;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
panic!("looked for again");
};
assert_eq!(probes[0], "https://map.example.net");
assert_eq!(probes.len(), candidates(address).len());
settings.servers.push(
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "http://example.net:8123".into(),
maps: vec![("minecraft:the_nether".into(), "n".into())]
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn one_look_a_server_at_a_time() {
let dir = scratch("claim");
let claim = Claim::take(&dir).unwrap();
assert!(Claim::take(&dir).is_none());
drop(claim);
assert!(Claim::take(&dir).is_some());
}
}
@@ -118,6 +118,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
positions: [
[0.0, 0.5, 0.0],
@@ -135,6 +136,11 @@ mod tests {
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
}],
}
+24 -18
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,6 +158,28 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -172,23 +194,7 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
let mut sources: Vec<(String, Value)> = brewing_sources(root);
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// A server's own chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
+246 -26
View File
@@ -14,12 +14,14 @@ pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
/// A level's build range, skylight and ceiling, from its dimension type
/// (`min_y`, `height`, `logical_height`, `has_skylight`, `has_ceiling`), as
/// `LevelHeightAccessor` and `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
/// `DimensionType.hasCeiling`: weather, spawning and maps read it.
pub has_ceiling: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
@@ -28,18 +30,21 @@ pub struct LevelShape {
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
has_ceiling: true,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: 0,
height: 256,
logical_height: 256,
@@ -50,6 +55,7 @@ impl LevelShape {
height: i32,
logical_height: i32,
sky_light: bool,
has_ceiling: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
@@ -59,6 +65,7 @@ impl LevelShape {
}
Ok(Self {
sky_light,
has_ceiling,
min_y,
height,
logical_height,
@@ -285,20 +292,122 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -347,14 +456,15 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -366,13 +476,47 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -564,19 +708,61 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -644,6 +830,10 @@ impl RolledLoot {
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Server-owned mod data attached to this chunk.
pub attachments: horizon_world::attachments::Attachments,
/// Attachment maps of the chunk's block entities, on a save snapshot.
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
@@ -670,6 +860,33 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
(|i| 0xA0 | (i / 256) as u8, "block"),
(|i| (i % 251) as u8, "full"),
(|_| 0x7C, "uniform"),
];
for (value, name) in cases {
let light = Light::packed(Box::new(std::array::from_fn(value)));
let form = match light {
Light::Sky(..) => "sky",
Light::Block(..) => "block",
Light::Full(_) => "full",
Light::Uniform(_) => "uniform",
};
assert_eq!(form, name);
for at in [0, 17, 1000, 4078] {
let mut run = [0u8; 18];
light.copy_run(at, &mut run);
let expected: Vec<u8> = (at..at + 18).map(value).collect();
assert_eq!(run.as_slice(), expected, "{name} at {at}");
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
}
}
}
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
@@ -809,12 +1026,15 @@ mod tests {
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
LevelShape::new(-64, 384, 384, true, false),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
assert_eq!(
LevelShape::new(0, 256, 128, false, true),
Ok(LevelShape::NETHER)
);
assert!(LevelShape::new(0, 256, 257, true, false).is_err());
assert!(LevelShape::new(-60, 384, 384, true, false).is_err());
}
#[test]
@@ -822,9 +1042,9 @@ mod tests {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
let tall = LevelShape::new(-64, 2096, 2096, true, false).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
let deep = LevelShape::new(-512, 256, 256, false, true).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
+206 -19
View File
@@ -4,6 +4,7 @@
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use crate::cit_glint::{Global, StackGlint};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
@@ -16,7 +17,7 @@ use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
let props = read_properties(&root.join(&path))?;
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
rules
}
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
/// be read.
pub fn read_properties(path: &Path) -> Option<Properties> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
Some(Properties::parse_equals_first(&text))
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// The pack's `cit.properties`.
pub global: Global,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
@@ -56,6 +64,7 @@ pub struct CitRules {
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
}
#[derive(Debug, Default)]
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
}
/// `rules` in priority order, the winner first.
@@ -106,6 +118,19 @@ impl CitRules {
self.rules.is_empty()
}
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
/// match no stack, so vanilla's glint and the other rules of their layer stay.
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
self.any_item.retain(|&i| keep(i as usize));
let named = &mut self.by_item[Kind::Enchantment as usize];
for list in named.values_mut() {
list.retain(|&i| keep(i as usize));
}
named.retain(|_, list| !list.is_empty());
lock(&self.enchanted).clear();
lock(&self.glints).clear();
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
@@ -123,13 +148,14 @@ impl CitRules {
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
/// GUI), in priority order.
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
@@ -138,7 +164,7 @@ impl CitRules {
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
(1, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
@@ -147,24 +173,62 @@ impl CitRules {
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
/// pack changes no glint. `foil` is whether the stack draws a glint at all
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
pub fn glint(
&self,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
foil: bool,
) -> Option<Arc<StackGlint>> {
let no_rules =
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
// Before the cache, which `foil` is not part of.
if !foil || (no_rules && self.global.use_glint) {
return None;
}
let comps = encode_components(stack);
let key = cache_key(
(2, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
if let Some(hit) = lock(&self.glints).get(&key) {
return Some(hit.clone());
}
let matched = self.enchantments(stack, hand, max_damage);
let glint = Arc::new(StackGlint::new(
&self.rules,
&matched,
stack.crafting_enchantments(),
&self.global,
));
remember(&self.glints, key, glint.clone());
Some(glint)
}
/// Whether rule `i` matches the stack drawn in `hand`.
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
hand: Hand,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
if rule.hand != Hand::Any && rule.hand != hand {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
@@ -646,7 +710,7 @@ mod tests {
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
@@ -659,4 +723,127 @@ mod tests {
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
}
/// `stack` with its `enchantment_glint_override` set to `on`.
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
let flag = (
"minecraft:enchantment_glint_override".to_owned(),
Tag::Byte(on.into()),
);
stack.data_mut().other.push(flag);
stack
}
#[test]
fn a_stack_glint_reads_the_book_and_the_pack_file() {
let mut cit = rules(&[
("sharp", "type=enchantment\nenchantments=sharpness"),
(
"keep",
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
),
]);
// An enchanted book's glint follows its stored enchantments.
let mut book = new("enchanted_book");
book.data_mut().stored_enchantments = vec![
(Arc::from("minecraft:sharpness"), 3),
(Arc::from("minecraft:mending"), 1),
];
let book = glint(&cit, &book, true).unwrap();
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
assert_eq!(weights, [0.75, 0.25]);
assert!(book.vanilla);
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
assert_eq!(plain.layers.len(), 1);
assert!(!plain.vanilla);
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
let mut bare = rules(&[("stick", "items=stick")]);
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
bare.global.use_glint = false;
assert!(
!glint(&bare, &overridden(new("stick"), true), false)
.unwrap()
.vanilla
);
cit.global.use_glint = false;
assert!(
glint(&cit, &overridden(new("stick"), true), false)
.unwrap()
.is_empty()
);
}
#[test]
fn only_a_stack_with_a_glint_gets_layers() {
let cit = rules(&[("any", "type=enchantment")]);
assert!(glint(&cit, &new("stick"), false).is_none());
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
assert!(glint(&cit, &off, false).is_none());
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
assert_eq!(
weights,
[1.0],
"nothing to share: the rule at full strength"
);
// The same stack, asked again without its glint, is not answered from the cache.
assert!(
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
.is_none()
);
}
#[test]
fn a_rule_left_out_of_the_glints_matches_nothing() {
let mut cit = rules(&[
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
("shown", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |cit: &CitRules| -> Vec<String> {
let glint = glint(cit, &sword, false).unwrap();
let stems = glint
.layers
.iter()
.map(|l| stem(cit, Some(l.rule)).unwrap());
stems.map(str::to_owned).collect()
};
// The heavier rule wins the layer, until its texture is missing.
assert_eq!(layers(&cit), ["broken"]);
let broken = (0..cit.rules.len())
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
.unwrap();
cit.keep_glints(|i| i != broken);
assert_eq!(layers(&cit), ["shown"]);
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
// None left: vanilla's glint stays.
cit.keep_glints(|_| false);
assert!(glint(&cit, &sword, false).is_none());
}
#[test]
fn an_off_hand_layer_draws_only_in_the_off_hand() {
let cit = rules(&[
("left", "type=enchantment\nlayer=1\nhand=off"),
("both", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |hand| -> Vec<&str> {
let glint = cit.glint(&sword, hand, 0, true).unwrap();
glint
.layers
.iter()
.map(|l| stem(&cit, Some(l.rule)).unwrap())
.collect()
};
assert_eq!(layers(Hand::Off), ["both", "left"]);
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
assert_eq!(layers(Hand::Main), ["both"]);
}
}
@@ -0,0 +1,319 @@
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
//! names its textures after the armour texture files of before 1.21.2
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
use crate::cit_assets::{rule_sprites, sprite_id};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
/// overlay (`leather_overlay`) or the main texture.
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
// The equipment assets renamed from their old textures; the rest kept their names.
let old = match path {
"turtle_scute" => "turtle",
other => other,
};
let layer = if leggings { 2 } else { 1 };
let suffix = if overlay { "_overlay" } else { "" };
format!("{old}_layer_{layer}{suffix}")
}
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
/// else its `texture`.
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
rule.textures
.iter()
.find(|(name, _)| name == layer)
.map(|(_, path)| path.as_str())
.or(rule.texture.as_deref())
}
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
/// vanilla's sprites.
fn atlas_name(resource: &str) -> Option<String> {
let id = sprite_id(resource)?;
Some(match id.strip_prefix("minecraft:") {
Some(name) => name.to_owned(),
None => id,
})
}
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
atlas_name(armor_texture(
rule,
&armor_layer_name(asset, leggings, overlay),
)?)
}
/// The atlas sprite an elytra rule puts on the wings.
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
atlas_name(armor_texture(rule, "elytra")?)
}
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
/// a name the atlas lacks; nothing is allocated without a rule.
pub fn worn_layer_sprite(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
) -> Option<String> {
let leggings = kind == "humanoid_leggings";
let overlay = vanilla.ends_with("_overlay");
layer_sprite(rule?, asset, leggings, overlay)
}
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
/// cape, else the equipment asset's own wings.
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
elytra_sprite(rule?)
}
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
pub fn worn_layer<R>(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
worn_layer_sprite(rule, asset, kind, vanilla)
.and_then(|name| get(&name))
.or_else(|| get(vanilla))
}
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
/// them to the equipment asset's own wings.
pub fn wings<R>(
rule: Option<&Rule>,
cape: Option<&str>,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
wings_sprite(rule)
.and_then(|name| get(&name))
.or_else(|| cape.and_then(&get))
}
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
fn rule(path: &str, text: &str) -> Rule {
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
}
#[test]
fn layers_take_their_old_texture_names() {
assert_eq!(
armor_layer_name("minecraft:diamond", false, false),
"diamond_layer_1"
);
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
assert_eq!(
armor_layer_name("minecraft:leather", false, true),
"leather_layer_1_overlay"
);
assert_eq!(
armor_layer_name("minecraft:leather", true, true),
"leather_layer_2_overlay"
);
assert_eq!(
armor_layer_name("minecraft:gold", false, false),
"gold_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:chainmail", true, false),
"chainmail_layer_2"
);
assert_eq!(
armor_layer_name("minecraft:turtle_scute", false, false),
"turtle_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:netherite", true, false),
"netherite_layer_2"
);
}
#[test]
fn a_named_layer_wins_over_the_generic_texture() {
let r = rule(
"assets/minecraft/optifine/cit/armor/blue.properties",
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
texture.diamond_layer_2=blue_legs\n",
);
let dir = "assets/minecraft/optifine/cit/armor";
assert_eq!(
armor_texture(&r, "diamond_layer_2"),
Some(format!("{dir}/blue_legs.png").as_str())
);
assert_eq!(
armor_texture(&r, "diamond_layer_1"),
Some(format!("{dir}/generic.png").as_str())
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
);
}
#[test]
fn without_a_generic_texture_other_layers_stay_vanilla() {
let r = rule(
"assets/minecraft/optifine/cit/a.properties",
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
);
assert_eq!(
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/dyed")
);
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
}
#[test]
fn a_vanilla_texture_path_keeps_its_atlas_name() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
);
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
}
#[test]
fn worn_layers_pick_the_rules_texture_for_their_layer() {
let r = rule(
"assets/minecraft/optifine/cit/dye.properties",
"type=armor\nitems=leather_chestplate leather_leggings\n\
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
texture.leather_layer_2=legs\n",
);
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
let pick = |kind: &str, vanilla: &str| {
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
};
let body = "entity/equipment/humanoid/leather";
assert_eq!(pick("humanoid", body), cit("body"));
assert_eq!(
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
cit("body_dye")
);
assert_eq!(
pick(
"humanoid_leggings",
"entity/equipment/humanoid_leggings/leather"
),
cit("legs")
);
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
assert_eq!(pick("humanoid_leggings", legs_dye), None);
// No rule draws vanilla's.
assert_eq!(
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
None
);
}
#[test]
fn wings_take_the_rules_texture() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
);
assert_eq!(
wings_sprite(Some(&r)).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/wings")
);
assert_eq!(wings_sprite(None), None);
}
#[test]
fn only_armor_and_elytra_rules_reach_the_atlas() {
let rules = [
rule(
"assets/minecraft/optifine/cit/sword.properties",
"items=diamond_sword\ntexture=sword\n",
),
rule(
"assets/minecraft/optifine/cit/plate.properties",
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
texture.iron_layer_2=legs\n",
),
rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
),
rule(
"assets/minecraft/optifine/cit/glint.properties",
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
),
];
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
assert_eq!(
got,
[
"horizon_cit:minecraft/optifine/cit/legs",
"horizon_cit:minecraft/optifine/cit/plate",
"horizon_cit:minecraft/optifine/cit/wings",
]
);
}
/// An atlas lookup that knows `names`.
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
}
#[test]
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
let iron = rule(
"assets/minecraft/optifine/cit/iron.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
);
let plate = "horizon_cit:minecraft/optifine/cit/plate";
let vanilla = "entity/equipment/humanoid/iron";
let layer = |rule: Option<&Rule>, names: &[&str]| {
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
};
assert_eq!(
layer(Some(&iron), &[plate, vanilla]).as_deref(),
Some(plate)
);
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
let elytra = rule(
"assets/minecraft/optifine/cit/w.properties",
"type=elytra\ntexture=wings",
);
let w = "horizon_cit:minecraft/optifine/cit/wings";
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
assert_eq!(wings(None, None, atlas(&[w])), None);
}
}
+184 -6
View File
@@ -5,8 +5,11 @@
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
//! `models/`) keeps its vanilla id, so it is not packed twice.
use horizon_assets::model::{CuboidModel, TextureSlot};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
/// The namespace of CIT files outside `textures/` and `models/`.
pub const NAMESPACE: &str = "horizon_cit";
@@ -55,12 +58,11 @@ pub fn resource(id: &str, ext: &str) -> Option<String> {
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
/// a warning.
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
/// values. A path no id can name is skipped with a warning.
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
for rule in rules.iter().filter(|r| keep(r.kind)) {
let named = rule.textures.iter().map(|(_, path)| path);
for path in rule.texture.iter().chain(named) {
match sprite_id(path) {
@@ -77,6 +79,122 @@ pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
out
}
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
/// JSON models are [`model_sprites`].
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| kind == Kind::Item)
}
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
/// that would leave the root.
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
let mut rest = value
.strip_prefix("./")
.or_else(|| value.starts_with("../").then_some(value))?;
let mut dir = file.rsplit_once('/')?.0;
while let Some(up) = rest.strip_prefix("../") {
dir = dir.rsplit_once('/')?.0;
rest = up;
}
let rest = rest
.strip_suffix(ext)
.and_then(|r| r.strip_suffix('.'))
.unwrap_or(rest);
let path = format!("{dir}/{rest}.{ext}");
horizon_optifine::paths::valid(&path).then_some(path)
}
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
let id = if ext == "png" {
sprite_id(&path)
} else {
model_id(&path)
}?;
Some((id, path))
}
/// Parents followed from a rule's model before the rest are left to the library.
const MAX_PARENTS: usize = 16;
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
/// with the parents they reach by relative path. Their `./x` texture and parent values become
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
/// library already. A file that does not read or parse is left out, and a rule's layer that
/// names it (or a model whose parents reach it) keeps its vanilla model.
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let mut seen = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.models.iter().map(|(_, path)| path);
for path in rule.model.iter().chain(named) {
load_model(root, path, 0, &mut seen, &mut out);
}
}
out
}
fn load_model(
root: &Path,
resource: &str,
depth: usize,
seen: &mut BTreeSet<String>,
out: &mut BTreeMap<String, CuboidModel>,
) {
let Some(id) = model_id(resource) else {
log::warn!("custom item textures: {resource} is not a valid model id");
return;
};
let ours = id
.strip_prefix(NAMESPACE)
.is_some_and(|r| r.starts_with(':'));
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
return;
}
let model = fs::read_to_string(root.join(resource))
.map_err(|e| e.to_string())
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
let mut model = match model {
Ok(model) => model,
Err(e) => {
log::warn!("custom item textures: skipped model {resource}: {e}");
return;
}
};
for slot in model.textures.values_mut() {
if let TextureSlot::Material(m) = slot
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
{
m.sprite = id;
}
}
let parent = model
.parent
.as_deref()
.and_then(|p| rebase(resource, p, "json"));
if let Some((id, path)) = parent {
model.parent = Some(id);
load_model(root, &path, depth + 1, seen, out);
}
out.insert(id, model);
}
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
let prefix = format!("{NAMESPACE}:");
models
.values()
.flat_map(|m| m.textures.values())
.filter_map(|slot| match slot {
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -161,4 +279,64 @@ mod tests {
root.join("assets/minecraft/textures/item/stick.png")
);
}
#[test]
fn rule_models_load_with_their_relative_textures_and_parents() {
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(cit.join("swords")).unwrap();
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
write(
"swords/flame.json",
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
);
write(
"base.json",
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
);
let props =
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
root.join(p).is_file()
})
.unwrap();
let models = load_models(&root, &[rule]);
let _ = fs::remove_dir_all(&root);
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
assert_eq!(
models.keys().cloned().collect::<Vec<_>>(),
[cit("base"), cit("swords/flame")]
);
let flame = &models[&cit("swords/flame")];
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
assert_eq!(
models[&cit("base")].parent.as_deref(),
Some("minecraft:item/handheld")
);
assert_eq!(
model_sprites(&models).into_iter().collect::<Vec<_>>(),
[cit("glow"), cit("swords/flame")]
);
}
#[test]
fn relative_values_stay_in_the_root() {
let file = "assets/minecraft/optifine/cit/a/m.json";
let at = |v: &str| relative(file, v, "png");
assert_eq!(
at("./x").as_deref(),
Some("assets/minecraft/optifine/cit/a/x.png")
);
assert_eq!(
at("./../x.png").as_deref(),
Some("assets/minecraft/optifine/cit/x.png")
);
assert_eq!(
at("../../x").as_deref(),
Some("assets/minecraft/optifine/x.png")
);
assert_eq!(at("item/x"), None);
assert_eq!(at("./../../../../../x"), None);
assert_eq!(at("./a/../x"), None);
}
}
@@ -0,0 +1,386 @@
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
//!
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
//! cycle's fading follow it, except where noted.
pub use horizon_optifine::cit::{Global, Method};
use crate::cit::read_properties;
use horizon_optifine::cit::Rule;
use std::path::Path;
/// The pack's global `cit.properties` under the overlay resource root: the first of
/// `Global::PATHS` that exists, else the defaults.
pub fn load_global(root: &Path) -> Global {
Global::PATHS
.iter()
.find_map(|path| read_properties(&root.join(path)))
.map(|props| Global::parse(&props))
.unwrap_or_default()
}
/// One glint layer a stack draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlintLayer {
/// The rule, an index into `CitRules::rules`.
pub rule: usize,
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
pub weight: f32,
/// Its `duration`, for `cycle`.
duration: f32,
}
/// What a stack's glint is made of, worked out when the stack changes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackGlint {
/// Bottom first (`layer` ascending).
pub layers: Vec<GlintLayer>,
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
pub vanilla: bool,
/// `fade` when the layers take turns (`method=cycle`).
cycle_fade: Option<f32>,
}
impl StackGlint {
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
/// (`ItemStack::crafting_enchantments`).
pub fn new(
rules: &[Rule],
matched: &[usize],
enchantments: &[(impl AsRef<str>, u32)],
global: &Global,
) -> Self {
// The highest priority rule of each layer.
let mut layers: Vec<usize> = Vec::new();
for &i in matched {
let layer = rules[i].glint.layer;
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
layers.push(i);
}
}
layers.sort_by_key(|&i| rules[i].glint.layer);
let cycle = global.method == Method::Cycle;
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
// the bottom layers, though, and caps cycles as well).
if let Some(cap) = global.cap.filter(|_| !cycle) {
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
layers = layers.split_off(layers.len().saturating_sub(cap));
}
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
let vanilla = global.use_glint
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
// CIT Resewn: shares are against every enchantment of the stack, not only those the
// layers name.
let levels = enchantments.iter().map(|(_, level)| *level as f32);
let total = match global.method {
Method::Average => levels.sum::<f32>(),
Method::Layered => levels.fold(0.0, f32::max),
Method::None | Method::Cycle => 0.0,
};
let shared = matches!(global.method, Method::Average | Method::Layered);
let layers = layers
.into_iter()
.map(|i| {
let rule = &rules[i];
let named = &rule.enchantments;
let weight = if !shared || named.is_empty() {
1.0
} else {
// The highest level among the enchantments the rule names whose level also
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
// stack has, whatever its level).
let in_range = |level: u32| {
let level = i32::try_from(level).unwrap_or(i32::MAX);
rule.enchantment_levels
.as_ref()
.is_none_or(|r| r.contains(level))
};
let level = enchantments
.iter()
.filter(|(id, level)| {
let id: &str = id.as_ref();
named.iter().any(|n| n == id) && in_range(*level)
})
.map(|(_, level)| *level as f32)
.fold(0.0, f32::max);
if total > 0.0 { level / total } else { 0.0 }
};
GlintLayer {
rule: i,
weight,
duration: rule.glint.duration,
}
})
.collect();
Self {
layers,
vanilla,
cycle_fade: cycle.then_some(global.fade),
}
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
/// its turn.
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
let layer = self.layers[index];
match self.cycle_fade {
None => layer.weight,
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
}
}
}
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
/// fades into the next over `fade` seconds, the last into the first. With no time at all
/// (every `duration` and `fade` 0) the bottom layer shows alone.
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
let n = layers.len();
let fade = f64::from(fade);
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
if n == 1 || period <= 0.0 {
return if index == 0 { 1.0 } else { 0.0 };
}
let mut t = seconds.rem_euclid(period);
for (i, layer) in layers.iter().enumerate() {
let duration = f64::from(layer.duration);
if t < duration {
return if i == index { 1.0 } else { 0.0 };
}
t -= duration;
// Defensive: the last fade also takes anything rounding might leave of the period
// (none seen), ending fully on the first layer.
if t < fade || i + 1 == n {
let into = if fade > 0.0 {
(t / fade).min(1.0) as f32
} else {
1.0
};
return if i == index {
1.0 - into
} else if (i + 1) % n == index {
into
} else {
0.0
};
}
t -= fade;
}
// Not reached, as the last layer returns above; no layer rather than a panic.
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
const DIR: &str = "assets/minecraft/optifine/cit";
/// An enchantment rule named `name` with `text` after `type` and `texture`.
fn rule(name: &str, text: &str) -> Rule {
let text = format!("type=enchantment\ntexture=glint\n{text}");
let path = format!("{DIR}/{name}.properties");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
fn global(text: &str) -> Global {
Global::parse(&Properties::parse_equals_first(text))
}
fn rules_of(layers: &StackGlint) -> Vec<usize> {
layers.layers.iter().map(|l| l.rule).collect()
}
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
#[test]
fn one_rule_per_layer_bottom_first() {
let rules = [
rule("a", "layer=2"),
rule("b", "layer=0"),
rule("c", "layer=2"),
rule("d", "layer=-1"),
];
// Priority order a, b, c, d: c loses layer 2 to a.
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [3, 1, 0]);
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [2]);
}
#[test]
fn average_and_layered_shares() {
let rules = [
rule("sharp", "enchantments=sharpness smite"),
rule("unb", "layer=1\nenchantments=unbreaking"),
rule("any", "layer=2"),
rule("fire", "layer=3\nenchantments=fire_aspect"),
];
let weights = |method: &str| {
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
};
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
// A rule naming no enchantment is at full strength, one the stack lacks at none.
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
assert_eq!(weights("method=cycle"), [1.0; 4]);
assert_eq!(weights("method=none"), [1.0; 4]);
}
#[test]
fn the_level_is_one_the_rules_levels_allow() {
let rules = [rule(
"holy",
"enchantments=sharpness smite\nenchantmentLevels=1-2",
)];
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
}
#[test]
fn cap_keeps_the_topmost_layers() {
let rules = [
rule("a", "layer=0"),
rule("b", "layer=1"),
rule("c", "layer=2"),
];
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
assert_eq!(rules_of(&g), [1, 2]);
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
assert!(g.is_empty());
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
assert_eq!(rules_of(&g), [2]);
// Cycles show every layer in turn.
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
assert_eq!(rules_of(&g), [0, 1, 2]);
}
#[test]
fn vanilla_glint() {
let plain = rule("plain", "");
let keep = rule("keep", "layer=1\nuseGlint=true");
let rules = [plain, keep];
let vanilla = |matched: &[usize], text: &str| {
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
};
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
assert!(!vanilla(&[0], ""), "a rule replaces it");
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
assert!(!vanilla(&[], "useGlint=false"));
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
// Only the layers `cap` keeps count.
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
assert!(!g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
assert!(g.is_empty() && g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
assert!(!g.vanilla);
}
#[test]
fn cycles_take_turns_and_fade() {
let rules = [
rule("a", "duration=2"),
rule("b", "layer=1\nduration=1"),
rule("c", "layer=2\nduration=1"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.5"),
);
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
// One layer always shows.
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
assert_eq!(one.intensity(0, 0.7), 1.0);
}
#[test]
fn a_cycle_always_shows_a_whole_layer() {
let rules = [
rule("a", "duration=0.1"),
rule("b", "layer=1\nduration=0.7"),
rule("c", "layer=2\nduration=0.2"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.3"),
);
// Every millisecond of the first two seconds, and just under a period, the last fade's
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
// defensive.)
let period: f64 = [0.1f32, 0.7, 0.2]
.map(|d| f64::from(d) + f64::from(0.3f32))
.iter()
.sum();
let last = f64::from_bits(period.to_bits() - 1);
for t in (0..2000_i32)
.map(|i| f64::from(i) * 0.001)
.chain([last, period + last])
{
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
}
// No time at all: the bottom layer alone, never NaN.
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
for t in [0.0, 0.5, 7.25] {
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
}
}
#[test]
fn the_first_global_file_wins() {
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(load_global(&root), Global::default());
let write = |path: &str, text: &[u8]| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, text).unwrap();
};
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
write(
"assets/minecraft/optifine/cit/cit.properties",
b"# caf\xE9\nmethod=cycle",
);
assert_eq!(load_global(&root).method, Method::Cycle);
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
assert_eq!(load_global(&root).method, Method::Cycle);
write(
"assets/minecraft/optifine/cit.properties",
b"method=layered",
);
assert_eq!(load_global(&root).method, Method::Layered);
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
let g = load_global(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
}
}
+199 -81
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -107,10 +154,16 @@ pub(crate) struct Uniform {
colors: [[f32; 4]; 4],
}
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let offset = drift(self.time, self.partial_tick, *w);
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
@@ -150,9 +203,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +326,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +367,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+219 -8
View File
@@ -182,12 +182,24 @@ where
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = CollisionContext::position(bounds.min_position().y);
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// [`Self::resolve`] with the entity's captured collision context.
pub fn resolve_with_context(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
context: CollisionContext<'_>,
) -> Result<Vec3> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
context,
bounds,
movement,
max_step,
@@ -211,6 +223,25 @@ impl SceneCollisions<'_, '_> {
) -> Result<Vec3> {
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
}
/// [`Self::resolve`], also stopped by the world border's walls when one is
/// given with the moving entity's x and z: `Entity.collectColliders` adds
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
pub fn resolve_in_border(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
/// also affect contextual block shapes (for example powder snow).
@@ -296,6 +327,88 @@ where
)
.unwrap_or(false)
}
impl SceneCollisions<'_, '_> {
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
/// building one: no block that can collide in its inner cells, and no block that
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
/// the scan decides). The same cells, blocks and section flags the scan reads.
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
})
}
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
@@ -309,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
Ok(self.section_reaches_out(chunk, section_y))
}
fn is_suffocating(
&mut self,
@@ -617,6 +724,25 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
@@ -769,3 +895,88 @@ mod shulker_collision_tests {
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
#[cfg(test)]
mod section_tests {
use super::*;
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
/// is left to it. Needs the prepared resources.
#[test]
fn cells_without_collision_leave_the_movement_as_resolving_does() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let state = |name: &str| assets.registry.default_state(name).unwrap();
// Torches, and a fence at the bottom of a section above air.
for x in 17..30 {
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
}
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
let scene = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
assert!(scene.cells_without_collision(torches));
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
assert!(scene.cells_without_collision(over_floor));
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut skipped, mut resolved) = (0, 0);
for i in 0..4000 {
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
let d = Vec3::new(
(next() - 0.5) * reach,
(next() - 0.5) * reach,
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
let mut full = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
let moved = full.resolve(bb, d, 0.0, false).unwrap();
if fast {
skipped += 1;
assert_eq!(
moved.components().map(f64::to_bits),
d.components().map(f64::to_bits),
"{bb:?} by {d:?}"
);
} else {
resolved += 1;
}
}
assert!(
skipped > 500 && resolved > 500,
"{skipped} skipped, {resolved} resolved"
);
}
}
+195 -9
View File
@@ -39,6 +39,8 @@ pub enum Pick {
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
Nothing,
}
/// A tile drawn over a face, after its own (`overlay*` methods).
@@ -387,7 +389,8 @@ impl ConnectedTextures {
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one.
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
/// with `orient=texture`.
pub fn pick<'b>(
&self,
state: u32,
@@ -396,6 +399,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
texture: impl Fn() -> Option<Axes>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
@@ -427,9 +431,58 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) =
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
&& let Some(pick) = self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
};
if picked == layer {
return pick;
}
let own = self.list(&self.by_state, state as usize);
// Rules tried on the face's sprite already (those before this one) are not tried
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
j != i
&& !(j < i && first.contains(&j))
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
});
for (_, r) in again {
// Joins by tile still compare the face's own sprite with its neighbours'.
match self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
return pick;
}
}
@@ -447,6 +500,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
let rule = &r.rule;
@@ -487,7 +541,12 @@ impl ConnectedTextures {
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
// The quad's own texture directions, whatever face `symmetry` stands it for.
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
// the standard way.
(Orient::Texture, _) if rule.method != Method::Overlay => {
texture().unwrap_or(Axes::cube(face))
}
_ => Axes::cube(face),
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
@@ -649,6 +708,10 @@ mod tests {
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
_ => None,
},
)
@@ -671,7 +734,7 @@ mod tests {
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -794,8 +857,26 @@ mod tests {
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
let lower = ctm.pick(
5,
3,
Face::North,
[x, 64, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
let upper = ctm.pick(
6,
3,
Face::North,
[x, 65, 0],
|_| 0,
|| None,
|| None,
|_| {},
);
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
@@ -809,7 +890,16 @@ mod tests {
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at = |y, biome: &'static str| {
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
ctm.pick(
1,
3,
Face::Up,
[0, y, 0],
|_| 0,
|| Some(biome),
|| None,
|_| {},
)
};
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
@@ -844,7 +934,7 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
@@ -925,6 +1015,7 @@ mod tests {
[0, 64, 0],
at,
|| None,
|| None,
|o| out.push(o.layer),
);
(out, pick)
@@ -962,6 +1053,7 @@ mod tests {
[0, 64, 0],
|o| if o == [0, 0, -1] { 1 } else { 0 },
|| None,
|| None,
|o| {
tint = Some((o.tint, o.translucent));
},
@@ -999,6 +1091,7 @@ mod tests {
[0, 64, 0],
door_north,
|| None,
|| None,
|o| got.push(o),
);
assert_eq!(
@@ -1039,6 +1132,99 @@ mod tests {
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn a_picked_tile_can_be_picked_again() {
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
// names that tile, so it draws in b0's place.
let chain = |t: &str| {
world(&[
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
("t.properties", t),
])
};
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
// A chained rule keeping the tile (<default>) leaves b's.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// One that names another block doesn't chain on glass.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// An overlay tried on the face's own sprite and on the picked tile draws once.
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
// tried: it chains.
let ctm = world(&[
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
(
"c.properties",
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
),
]);
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
world(&[(
"glass.properties",
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
)])
};
// A mirrored north face (a vanilla pane's): its texture's right is east.
let mirrored = || {
Some(Axes {
right: [1, 0, 0],
up: [0, 1, 0],
})
};
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
let at = |ctm: &ConnectedTextures| {
ctm.pick(
1,
3,
Face::North,
[0, 64, 0],
east,
|| None,
mirrored,
|_| {},
)
};
// Glass to the east is to the texture's right: the row's left end.
assert_eq!(at(&rule("texture")), tile(1000));
// By the cube's directions it is to the left: the row's right end.
assert_eq!(at(&rule("none")), tile(1002));
// A plain overlay keeps the standard directions: glass north of a top face is above it
// (tile 15) whatever the quad's texture says.
let overlay = world(&[(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
)]);
let turned = || {
Some(Axes {
right: [0, 0, 1],
up: [1, 0, 0],
})
};
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
let mut got = Vec::new();
overlay.pick(
2,
7,
Face::Up,
[0, 64, 0],
north,
|| None,
turned,
|o| got.push(o.layer),
);
assert_eq!(got, [1015]);
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
+78 -9
View File
@@ -257,6 +257,17 @@ impl SkullSkin {
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
n if n.ends_with("shulker_box") => Some(27),
_ => None,
}
}
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
@@ -389,6 +400,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -419,15 +433,56 @@ impl BlockEntity {
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::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]))
Some(Self::Chest(vec![None; container_size(name)?]))
}
/// `BlockEntity.isValidBlockState`: whether this kind of entity belongs
/// under `name`'s block — vanilla keeps a set block's existing entity
/// only when its type is in the block's set, and drops it for the
/// block's own otherwise. Each kind fits the blocks `for_block` maps to
/// it (a furnace is its kind's, a sign only its standing or hanging
/// kind); a mod block's own can't tell its block here, so a non-vanilla
/// name is trusted — a name change drops it either way.
pub fn fits(&self, name: &str) -> bool {
match self {
Self::Chest(items) => container_size(name) == Some(items.len()),
Self::Furnace(f) => FurnaceKind::from_block(name) == Some(f.kind),
Self::Sign(s) => crate::signs::sign_kind(name) == Some(s.hanging),
Self::Sculk(s) => {
crate::sculk::SculkEntity::for_block(name).is_some_and(|n| n.id() == s.id())
}
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
Self::Campfire(_) => {
matches!(name, "minecraft:campfire" | "minecraft:soul_campfire")
}
Self::Brushable(_) => matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
),
Self::Brewing(_) => name == "minecraft:brewing_stand",
Self::Beacon(_) => name == "minecraft:beacon",
Self::Jukebox(_) => name == "minecraft:jukebox",
Self::Lectern(_) => name == "minecraft:lectern",
Self::CreakingHeart(_) => name == "minecraft:creaking_heart",
Self::Spawner(_) => name == "minecraft:spawner",
Self::TrialSpawner(_) => name == "minecraft:trial_spawner",
Self::Vault(_) => name == "minecraft:vault",
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
Self::JigsawBlock(_) => name == "minecraft:jigsaw",
Self::PotentSulfur(_) => name == "minecraft:potent_sulfur",
Self::ChiseledBookshelf(_) => name == "minecraft:chiseled_bookshelf",
}
}
pub fn id(&self) -> &str {
match self {
@@ -729,6 +784,20 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// 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() {
+95 -7
View File
@@ -20,6 +20,53 @@ thread_local! {
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
/// The native mod hook this thread is inside, for the panic hook to name.
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
}
/// A call into one hook of a native mod.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeModCall {
/// The mod's `[mod] id`, None in a build that didn't name its mods.
pub id: Option<&'static str>,
/// Its place in dispatch order, which names it then.
pub index: usize,
pub hook: &'static str,
}
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
/// unwinds. A hook called from a hook is named by the inner call.
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
NativeModGuard {
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
}
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
outer: Option<NativeModCall>,
}
impl Drop for NativeModGuard {
fn drop(&mut self) {
IN_NATIVE_MOD.with(|c| c.set(self.outer));
}
}
/// `message`, with the native mod hook this thread is inside, if any.
fn attributed(message: String) -> String {
match IN_NATIVE_MOD.with(Cell::get) {
Some(NativeModCall {
id: Some(id), hook, ..
}) => format!("{message}\nin native mod {id}, hook {hook}"),
Some(NativeModCall {
id: None,
index,
hook,
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
None => message,
}
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
@@ -394,10 +441,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
match info.location() {
attributed(match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
}
})
}
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
@@ -494,7 +541,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
@@ -512,7 +560,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
/// panic inside a native mod's hook names the mod and the hook.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -651,6 +700,25 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_native_mods_hook_names_it() {
let outer = enter_native_mod(NativeModCall {
id: None,
index: 1,
hook: "render",
});
let inner = enter_native_mod(NativeModCall {
id: Some("b"),
index: 0,
hook: "tick",
});
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
drop(inner);
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
drop(outer);
assert_eq!(attributed("boom".into()), "boom");
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -762,17 +830,37 @@ mod tests {
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
let caught = std::sync::Arc::new(Mutex::new(None));
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if panic_message(info).contains("the test crash") {
*sink.lock().unwrap() = Some(panic_report(info, "client"));
sink.lock().unwrap().push(panic_report(info, "client"));
}
}));
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
// The same panic inside a native mod's hook, which the hook sees before the unwind.
let _ = std::panic::catch_unwind(|| {
let _guard = enter_native_mod(NativeModCall {
id: Some("a"),
index: 0,
hook: "tick",
});
panic!("the test crash")
});
std::panic::set_hook(previous);
let report = caught.lock().unwrap().take().expect("the hook ran");
// A caught panic leaves the thread as it found it.
assert_eq!(attributed("boom".into()), "boom");
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
assert_eq!(reports.len(), 2, "the hook ran for both panics");
let in_mod = reports.pop().unwrap();
assert!(
in_mod.error.ends_with("\nin native mod a, hook tick"),
"{}",
in_mod.error
);
let report = reports.pop().unwrap();
assert!(!report.error.contains("native mod"));
assert!(report.error.contains("the test crash"));
assert!(report.error.contains("crash.rs"));
// The first frame is the test's own code, not the panic machinery.
+5 -1
View File
@@ -121,7 +121,11 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs = Vec::new();
+7 -5
View File
@@ -421,31 +421,33 @@ impl StatusFlags {
/// The attribute modifier an effect adds at amplifier 0
/// (`MobEffect.addAttributeModifier`): (attribute, id, amount, operation).
/// Each level multiplies the amount (`amplifier + 1`).
/// Each level multiplies the amount (`amplifier + 1`). Vanilla's amounts are
/// `float` literals widened to the modifier's `double`, so `f64::from` the
/// `f32` to keep the low digits.
pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64, &'static str)> {
match effect {
"speed" => Some((
"minecraft:movement_speed",
"minecraft:effect.speed",
0.2,
f64::from(0.2f32),
"add_multiplied_total",
)),
"slowness" => Some((
"minecraft:movement_speed",
"minecraft:effect.slowness",
-0.15,
f64::from(-0.15f32),
"add_multiplied_total",
)),
"haste" => Some((
"minecraft:attack_speed",
"minecraft:effect.haste",
0.1,
f64::from(0.1f32),
"add_multiplied_total",
)),
"mining_fatigue" => Some((
"minecraft:attack_speed",
"minecraft:effect.mining_fatigue",
-0.1,
f64::from(-0.1f32),
"add_multiplied_total",
)),
"strength" => Some((
+60 -22
View File
@@ -31,6 +31,9 @@ struct Globals {
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -47,8 +50,9 @@ 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,
// The distances fog reads: .x spherical, .y cylindrical. One vec2 rather than two floats, so that
// the fragment stage stays within 14 user-defined inputs plus front_facing (GL's limit of 15, #8195).
@location(2) fog_distance: vec2<f32>,
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
@location(5) overlay_color: vec4<f32>,
@location(4) @interpolate(flat) overlay: u32,
@@ -69,6 +73,8 @@ struct Out {
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -107,6 +113,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -120,6 +136,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
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);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -131,7 +148,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
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);
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
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);
@@ -140,11 +158,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
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.fog_distance.x = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.fog_distance.y = 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); }
if overlay == 4u { out.fog_distance.x = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@@ -276,7 +294,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
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)));
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.fog_distance.x, 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
@@ -295,8 +313,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
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));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -310,8 +328,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
fn shadow_color(in: Out) -> 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));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -328,8 +346,16 @@ fn shadow_color(in: Out) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if in.tier == 0u || texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -348,15 +374,23 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 1.0);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let fog = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
@@ -375,8 +409,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
let added = color.rgb * color.a;
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
// none (the End sky, the End flash) stays brightest.
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
let tier = select(in.tier, 4u, in.tier == 0u);
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
@@ -391,8 +429,8 @@ fn energy_swirl_base(in: Out) -> vec4<f32> {
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
@@ -535,7 +573,7 @@ fn weather_color(in: Out) -> vec4<f32> {
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));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -120,7 +120,7 @@ pub struct Environment {
tracks: HashMap<String, Track>,
base: HashMap<String, [f32; 4]>,
}
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Sky {
pub sky_color: [f32; 3],
pub fog_color: [f32; 3],
+256 -10
View File
@@ -8,6 +8,8 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -60,6 +62,10 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
/// The Select Resource Packs screen's Add button, which browses
/// Modrinth. Off by default: it is Horizon's own and the screen is
/// otherwise vanilla's (tools/test_pack_screen_parity.py).
pub pack_browser: bool,
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
@@ -120,14 +126,48 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
/// on by default as vanilla); off, the wheel only scrolls the list.
pub wheel_cycles_settings: bool,
/// The built-in music tracks the Music page turned off (sound ids): they
/// leave every pool they play in (`musicOff`, empty by default: vanilla's
/// random selection).
pub music_off: Vec<String>,
/// The Music page: the Music... button on Music & Sounds, playing a
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (all by default), and their auras and
/// trails (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_auras: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
pub three_d_skin_layers: bool,
}
impl Default for Extras {
fn default() -> Self {
Self {
pack_browser: false,
fullbright: false,
dynamic_lights: false,
zoom: false,
@@ -160,8 +200,19 @@ impl Default for Extras {
chat_indicator: true,
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_auras: false,
three_d_skin_layers: false,
}
}
}
@@ -187,6 +238,7 @@ impl Extras {
_ => d,
};
match key.trim() {
"packBrowser" => e.pack_browser = flag(e.pack_browser),
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
@@ -240,8 +292,29 @@ impl Extras {
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
"musicOff" => {
e.music_off = value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
_ => {}
}
}
@@ -249,7 +322,8 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -287,10 +361,40 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
bool_text(self.cosmetics_auras),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
)
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
{
self.three_d_skin_layers = value == "true";
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
@@ -402,6 +506,10 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -412,6 +520,8 @@ pub enum Item {
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
/// The Music... page on Music & Sounds (`Music page`).
MusicPage,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -425,13 +535,22 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
let mut rows = vec![
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
@@ -451,7 +570,20 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
Ok(MusicPage),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
]);
}
rows
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -463,16 +595,23 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MusicPage => "horizon.extras.music_page",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -484,16 +623,23 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MusicPage => "horizon.extras.music_page.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -514,6 +660,7 @@ impl Item {
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MusicPage => "Music page",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -522,8 +669,14 @@ impl Item {
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -535,8 +688,9 @@ impl Item {
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras => {
return None;
}
})
@@ -560,6 +714,9 @@ impl Item {
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MusicPage => {
"Music & Sounds gets a Music... page: play any built-in track now, or turn one off so the game stops picking it. Nothing changes until you turn a track off."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -588,6 +745,22 @@ impl Item {
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
Item::PackBrowser => {
"Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -596,6 +769,7 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::MusicPage => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
@@ -603,7 +777,10 @@ impl Item {
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers | Item::CosmeticsAuras => Some(Cost::Low),
_ => None,
}
}
@@ -616,6 +793,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MusicPage => e.music_page,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
@@ -623,8 +801,12 @@ impl Item {
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
_ => return None,
})
}
@@ -632,15 +814,31 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.cosmetics_others {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`_back` for the right button: every item left is a switch,
/// so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, _back: bool) {
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
@@ -648,6 +846,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MusicPage => e.music_page = !e.music_page,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
@@ -655,8 +854,17 @@ impl Item {
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
let o = e.cosmetics_others;
e.cosmetics_others = if back { o.next().next() } else { o.next() };
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::SortKey => {}
}
}
@@ -673,6 +881,34 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
assert_eq!(
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::NameTags);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::Hidden);
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(Extras::parse(&e.serialize()), e);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Others::NameTags
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Others::Hidden
);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
@@ -680,7 +916,8 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -721,6 +958,15 @@ mod tests {
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
e.music_off = vec![
"minecraft:music/game/calm1".into(),
"minecraft:music.menu".into(),
];
assert!(!e.music_page);
Item::MusicPage.cycle(&mut e, false);
assert_eq!(Item::MusicPage.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+1 -1
View File
@@ -21,7 +21,7 @@ pub enum FogType {
}
/// The fog uniform (`FogData`).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FogData {
pub color: [f32; 4],
pub environmental_start: f32,
+194 -23
View File
@@ -104,8 +104,11 @@ struct FontSet {
providers: Vec<Provider>,
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use).
by_width: Option<HashMap<u32, Vec<char>>>,
/// `SelectedGlyphs.nonFishy` of the characters whose first glyph has a fishy advance (the
/// others use `glyphs`): what obfuscated text draws.
non_fishy: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use), by `ceil(advance)`.
by_width: Option<HashMap<i32, Vec<char>>>,
}
/// The atlas region glyphs are rasterized into, filled in rows.
struct Region {
@@ -258,6 +261,20 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
})
.map_or(0, |x| x + 1)
}
/// `FontSet.hasFishyAdvance`: an advance under 0 or over 32. Such a glyph is still the one drawn
/// (`SelectedGlyphs.any` is the first provider's); only `SelectedGlyphs.nonFishy`, what obfuscated
/// text draws, moves on to the next provider that has the character. Packs rely on it: a `space`
/// provider's kerning runs from -512 to 512, and a tall bitmap's glyphs pass 32. Horizon keeps one
/// advance per glyph and tests it here.
fn fishy_advance(advance: f32) -> bool {
!(0.0..=32.0).contains(&advance)
}
/// A Unihex glyph's advance (`UnihexProvider.Glyph.advance`): half its width
/// plus one, the width being the bits from its left to its right column.
fn hex_advance(h: &HexGlyph) -> f32 {
let width = h.right.saturating_sub(h.left) + 1;
(width / 2 + 1) as f32
}
/// `BitmapProvider.Definition.load`: each character's glyph, from the atlas
/// when the sheet is in it (`pixels` its RGBA), else from the sheet's own
/// pixels, copied into the atlas when first drawn.
@@ -661,6 +678,7 @@ impl Font {
FontSet {
providers,
glyphs: Default::default(),
non_fishy: Default::default(),
by_width: None,
}
}
@@ -669,6 +687,7 @@ impl Font {
let mut cache = self.cache.borrow_mut();
for (_, set) in &mut cache.sets {
set.glyphs.clear();
set.non_fishy.clear();
set.by_width = None;
}
cache.region.cursor = (0, 0);
@@ -722,7 +741,7 @@ impl Font {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
let found = self.resolve(set, region, c, false);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
@@ -734,12 +753,47 @@ impl Font {
set.glyphs.insert(c, glyph);
glyph
}
fn resolve(&self, set: &mut FontSet, region: &mut Region, c: char) -> Option<Glyph> {
/// `FontSet.getRandomGlyph`'s `SelectedGlyphs.nonFishy`: the glyph of the first provider whose
/// advance is not fishy, else the missing glyph. A character whose first glyph is fine has
/// the same one for both.
fn glyph_non_fishy(&self, id: &str, c: char) -> Glyph {
let any = self.glyph_of(id, c);
if !fishy_advance(any.advance) {
return any;
}
let mut cache = self.cache.borrow_mut();
let Cache { sets, region, .. } = &mut *cache;
let Some((_, set)) = sets.iter_mut().find(|(k, _)| k == id) else {
return self.missing;
};
if let Some(g) = set.non_fishy.get(&c) {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c, true);
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.non_fishy.insert(c, glyph);
glyph
}
fn resolve(
&self,
set: &mut FontSet,
region: &mut Region,
c: char,
non_fishy: bool,
) -> Option<Glyph> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c) {
if let Some(a) = advances.get(&c)
&& !(non_fishy && fishy_advance(*a))
{
return Some(Glyph {
rect: None,
left: 0.0,
@@ -753,8 +807,12 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) => return Some(*g),
Some(BitmapGlyph::Sheet(cell)) => {
Some(BitmapGlyph::Atlas(g)) if !(non_fishy && fishy_advance(g.advance)) => {
return Some(*g);
}
Some(BitmapGlyph::Sheet(cell))
if !(non_fishy && fishy_advance(cell.glyph.advance)) =>
{
let (stride, pixels) = (&cell.sheet.0, &cell.sheet.1);
let mut rgba = Vec::with_capacity((cell.w * cell.h * 4) as usize);
for y in cell.y..cell.y + cell.h {
@@ -768,17 +826,21 @@ impl Font {
..cell.glyph
});
}
None => {}
_ => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied() {
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
return self.rasterize_hex(region, h);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c) {
if let Some(id) = ttf.ids.get(&c)
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
{
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
Some((w, h, rgba)) => {
@@ -813,7 +875,7 @@ impl Font {
top: 0.0,
right: width as f32 / 2.0,
bottom: 8.0,
advance: (width / 2 + 1) as f32,
advance: hex_advance(&h),
offset: 0.5,
})
}
@@ -830,7 +892,7 @@ impl Font {
pub fn random_glyph(&self, style: &Style, advance: f32) -> Glyph {
use horizon_core::random::RandomSource;
let id = style.font.as_deref().unwrap_or(DEFAULT);
let width = advance.ceil() as u32;
let width = advance.ceil() as i32;
let pick = {
let mut cache = self.cache.borrow_mut();
let Cache { sets, random, .. } = &mut *cache;
@@ -848,9 +910,9 @@ impl Font {
chars.get(i as usize).copied()
})
};
pick.map_or(self.missing, |c| self.glyph_of(id, c))
pick.map_or(self.missing, |c| self.glyph_non_fishy(id, c))
}
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<u32, Vec<char>> {
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<i32, Vec<char>> {
let (uniform, japanese) = (self.uniform, self.japanese);
let mut chars = std::collections::BTreeSet::new();
let mut in_atlas = false;
@@ -872,16 +934,16 @@ impl Font {
// Nothing in the atlas: the glyphs below U+0250, rasterized as used.
chars.extend((0..0x250u32).filter_map(char::from_u32));
}
let mut out: HashMap<u32, Vec<char>> = HashMap::new();
let mut out: HashMap<i32, Vec<char>> = HashMap::new();
for c in chars {
if let Some(advance) = self.provider_advance(set, c) {
out.entry(advance.ceil() as u32).or_default().push(c);
out.entry(advance.ceil() as i32).or_default().push(c);
}
}
out
}
/// A character's advance in `set` (its first provider that has it),
/// without rasterizing it.
/// A character's advance in `set` (its first provider that has it, fishy or not, as the
/// by-width table of `FontSet.reload` takes it), without rasterizing it.
fn provider_advance(&self, set: &mut FontSet, c: char) -> Option<f32> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
@@ -893,16 +955,19 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) => return Some(g.advance),
Some(BitmapGlyph::Sheet(cell)) => return Some(cell.glyph.advance),
None => {}
Some(BitmapGlyph::Atlas(g)) => {
return Some(g.advance);
}
Some(BitmapGlyph::Sheet(cell)) => {
return Some(cell.glyph.advance);
}
_ => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)) {
let width = h.right.saturating_sub(h.left) + 1;
return Some((width / 2 + 1) as f32);
return Some(hex_advance(h));
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
@@ -947,6 +1012,16 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
@@ -1366,6 +1441,102 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// `FontSet.computeGlyphInfo`: `SelectedGlyphs.any` is the first provider's glyph, fishy
/// advance or not, and is what is drawn and measured; `nonFishy`, what obfuscated text draws,
/// is the first provider whose advance is within 0..32, else the missing glyph.
#[test]
fn a_fishy_glyph_is_drawn_and_only_obfuscated_text_falls_through() {
// 64 opaque columns: the first provider's 'a' advances 65, past the 32 vanilla
// calls fishy, and the narrow provider's 'a' advances 3.
let wide = png(64, 8, |_, _| true);
let narrow = png(16, 8, |x, _| x < 2);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/wide.png","ascent":7,"chars":["a"]},{"type":"bitmap","file":"minecraft:font/narrow.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"fishy",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/wide.png", &wide),
("assets/minecraft/textures/font/narrow.png", &narrow),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let drawn = font.glyph('a');
assert_eq!(drawn.advance, 65.0, "the first provider's glyph is drawn");
assert!(drawn.rect.is_some());
assert_eq!(font.advance('a', &Style::default()), 65.0);
// The by-width table files 'a' under its first glyph's 65; the glyph it then draws is
// the narrow provider's (`nonFishy`).
assert_eq!(font.random_glyph(&Style::default(), 65.0).advance, 3.0);
// Nothing else is 3 wide in the table, and the missing glyph is 6.
assert_eq!(font.random_glyph(&Style::default(), 3.0).advance, 6.0);
}
/// A `space` provider's shift glyphs (kerning in server packs: -512 to 512) are drawn and
/// measured as they are, and `nonFishy` has nothing else to draw for them.
#[test]
fn space_glyphs_with_any_advance_are_kept() {
let shifts: Vec<i32> = (0..10)
.map(|i| -(1 << i))
.chain((0..10).map(|i| 1 << i))
.collect();
let advances: Vec<String> = shifts
.iter()
.enumerate()
.map(|(i, a)| format!("\"\\ue{:03x}\": {a}", i))
.collect();
let def = format!(
r#"{{"providers":[{{"type":"space","advances":{{{}}}}}]}}"#,
advances.join(",")
);
let root = root(
"space-shifts",
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
for (i, a) in shifts.iter().enumerate() {
let c = char::from_u32(0xe000 + i as u32).unwrap();
assert_eq!(font.glyph(c).advance, *a as f32, "{c:?}");
assert_eq!(font.advance(c, &Style::default()), *a as f32);
}
// Obfuscated text asks for a glyph of a given width: a fishy space has none to give.
let c = char::from_u32(0xe000 + 19).unwrap();
assert_eq!(font.glyph(c).advance, 512.0);
assert_eq!(
font.glyph_non_fishy(DEFAULT, c).advance,
6.0,
"the missing glyph"
);
assert_eq!(font.random_glyph(&Style::default(), 512.0).advance, 6.0);
}
/// Nexo's GUI glyphs: a bitmap `height` of 287 with no advance, so every glyph wider than 31
/// pixels has a fishy advance, and is still drawn.
#[test]
fn a_wide_bitmap_glyph_is_drawn() {
let sheet = png(40, 287, |_, _| true);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/gui.png","height":287,"ascent":65,"chars":["a"]}]}"#;
let root = root(
"wide-bitmap",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/gui.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let g = font.glyph('a');
assert_eq!(g.advance, 41.0, "the glyph's 40 columns and one");
assert!(g.rect.is_some(), "drawn, not the missing glyph");
assert!(fishy_advance(g.advance));
assert_eq!(
font.glyph_non_fishy(DEFAULT, 'a').advance,
6.0,
"excluded from what obfuscated text draws"
);
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
/// part only; the next part is still measured and can break.
#[test]
+687
View File
@@ -0,0 +1,687 @@
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
//!
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
//! turned about x, or turned with `uvlock`, join a quarter turn off.
//!
//! The format is `horizon_optifine::fusion`'s.
use crate::connected::{Overlay, Pick, TileLayer};
use crate::models::StateModels;
use crate::sprites::Sprites;
use horizon_optifine::cem::json::Json;
use horizon_optifine::ctm::connect::{self, Axes, Face};
use horizon_optifine::fusion::{
Connection, Layout, Tiles,
model::{Connections, ModelType},
predicate::Query,
texture::{Kind, Meta},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
layout: Layout,
tiles: Vec<Option<TileLayer>>,
}
/// A sheet and what it joins by, blocks as sorted state ids.
struct Rule {
sheet: usize,
connection: Connection<Vec<u32>>,
}
#[derive(Default)]
pub struct FusionTextures {
sheets: Vec<Sheet>,
/// By [`crate::models::BakedFace::fusion`].
rules: Vec<Rule>,
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
blocks: Vec<u32>,
}
/// A model's reference to a texture or another model (`block/glass`), namespaced.
fn namespaced(reference: &str) -> String {
horizon_optifine::matchers::namespaced(reference)
}
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{ns}/models/{path}.json")),
)
.ok()?;
Json::parse(&text).ok()
}
/// What a model's face draws and joins by: its sprite, its side in the model, and its
/// predicates (`None`: the texture's own).
type ModelFace = (String, Face, Option<Connection>);
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
let Some(model) = read_model(root, name) else {
return Vec::new();
};
if ModelType::of(&model) != Some(ModelType::Connecting) {
return Vec::new();
}
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
// levels is a loop.
let mut chain = vec![model];
while chain.len() < 16
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
&& let Some(parent) = read_model(root, &namespaced(parent))
{
chain.push(parent);
}
// Parents first, so a child's keys replace theirs.
let mut connections = Connections::Textures;
let mut textures: HashMap<&str, &str> = HashMap::new();
for model in chain.iter().rev() {
match Connections::of(model) {
Ok(own) => connections = own.inherit(&connections),
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
}
for (key, value) in model
.get("textures")
.and_then(Json::as_object)
.unwrap_or(&[])
{
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
if let Some(value) = value {
textures.insert(key.as_str(), value);
}
}
}
// A `#key` as vanilla resolves it.
let resolve = |reference: &str| {
let mut value = reference;
for _ in 0..16 {
match value.strip_prefix('#') {
Some(key) => value = textures.get(key)?,
None => return Some(namespaced(value)),
}
}
None
};
// The elements are the nearest model's that has some.
let elements = chain
.iter()
.find_map(|m| m.get("elements")?.as_array())
.unwrap_or(&[]);
let mut out: Vec<ModelFace> = Vec::new();
let faces = elements
.iter()
.filter_map(|e| e.get("faces")?.as_object())
.flatten();
for (side, face) in faces {
let side = match side.as_str() {
"down" => Face::Down,
"up" => Face::Up,
"north" => Face::North,
"south" => Face::South,
"west" => Face::West,
"east" => Face::East,
_ => continue,
};
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
continue;
};
let Some(sprite) = resolve(texture) else {
continue;
};
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
let key = face.get("connections").and_then(Json::as_str);
out.push((sprite, side, connections.for_face(key, texture).cloned()));
}
}
out
}
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
let mut n = face.normal();
for _ in 0..x % 4 {
// x=90 takes up to north.
n = [n[0], n[2], -n[1]];
}
for _ in 0..y % 4 {
// y=90 takes north to east.
n = [-n[2], n[1], n[0]];
}
Face::ALL
.into_iter()
.find(|f| f.normal() == n)
.expect("a quarter turn maps faces onto faces")
}
/// The connecting textures under `root`, by sprite name.
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let mut out = HashMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
out
}
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
fn sheet(
root: &Path,
sprites: &mut Sprites,
name: &str,
meta: Meta,
) -> Option<(Sheet, Connection)> {
let Kind::Connecting {
layout,
connections,
per_tile_animation,
..
} = meta.kind
else {
return None;
};
let tiles = sprites
.insert_tiles(root, name, layout.grid(), per_tile_animation)
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
.ok()?;
// A layer past the mesher's `u16` leaves the sheet to vanilla.
let tiles = tiles
.into_iter()
.map(|t| match t {
None => Some(None),
Some(l) => Some(Some(TileLayer::Layer {
layer: u16::try_from(l).ok()?,
translucent: sprites.translucent[l as usize],
opaque: sprites.opaque[l as usize],
})),
})
.collect::<Option<Vec<_>>>()?;
Some((Sheet { layout, tiles }, connections))
}
impl FusionTextures {
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
/// shows one gets its rule.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
catalog: &mut StateModels,
) -> Self {
let mut textures = connecting_textures(root);
if textures.is_empty() {
return Self::default();
}
// The layers models show the sheets as: only their models are read.
let shown: std::collections::HashSet<u32> = textures
.keys()
.filter_map(|name| sprites.layers.get(name).copied())
.collect();
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
let mut rules: Vec<(usize, Connection)> = Vec::new();
for (variant, model) in catalog.models_mut() {
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
continue;
}
let found = models
.entry(namespaced(&variant.model))
.or_insert_with_key(|name| connecting_model(root, name));
for face in &mut model.faces {
let sprite = &face.quad.material.sprite;
// The model's face on the side this one turned to, else any with its sprite.
let side = Face::ALL[face.quad.facing as usize];
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
let Some((_, _, connection)) = drawing
.clone()
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
.or_else(|| drawing.next())
else {
continue;
};
// The sheet, cut into tiles on first use.
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
let meta = textures.remove(sprite)?;
sheets.push(sheet(root, sprites, sprite, meta)?);
Some(sheets.len() - 1)
});
let Some(sheet) = sheet else {
continue;
};
let connection = connection
.clone()
.unwrap_or_else(|| sheets[sheet].1.clone());
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
Some(rule) => rule,
None => {
rules.push((sheet, connection));
rules.len() - 1
}
};
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
}
}
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
Self::build(sheets, rules, states)
}
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
fn build<'a>(
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
) -> Self {
let states: Vec<_> = states.collect();
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
let mut names = HashMap::new();
let blocks = (0..states.len() as u32)
.map(|id| match states[id as usize] {
Some((name, _)) => {
block_states.entry(name).or_default().push(id);
let next = names.len() as u32;
*names.entry(name).or_insert(next)
}
None => u32::MAX,
})
.collect();
// Block lists to the states they match.
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
let mut ids: Vec<u32> = list
.iter()
.flat_map(|m| {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
candidates.iter().copied().filter(|&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
})
})
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let rules = rules
.into_iter()
.map(|(sheet, connection)| Rule {
sheet,
connection: connection.map_blocks(&mut resolve),
})
.collect();
Self {
sheets,
rules,
blocks,
}
}
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
/// pieces, tinted as the quad, go to `overlay`.
#[allow(clippy::too_many_arguments)]
pub fn pick(
&self,
rule: NonZeroU16,
state: u32,
face: Face,
tint: i32,
neighbour: impl Fn([i32; 3]) -> u32,
covers: impl Fn(u32) -> bool,
mut overlay: impl FnMut(Overlay),
) -> Option<Pick> {
let rule = self.rules.get(usize::from(rule.get() - 1))?;
let normal = face.normal();
let offsets = connect::offsets(Axes::cube(face));
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
let mask = rule.connection.mask(|bit, query| {
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
match query {
Query::SameBlock => {
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
}
Query::SameState => neighbour([x, y, z]) == state,
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
Query::FaceVisible => !covers(in_front()),
}
});
let sheet = &self.sheets[rule.sheet];
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
// never need.
Some(match sheet.layout.tiles(mask) {
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
Tiles::Quarters(q) => match q.map(tile) {
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
_ => Pick::Nothing,
},
Tiles::Overlay(mask) => {
for t in Layout::overlay_tiles(mask) {
if let Some(TileLayer::Layer {
layer, translucent, ..
}) = tile(t)
{
overlay(Overlay {
layer,
translucent,
tint: (tint >= 0).then_some((tint, None)),
});
}
}
Pick::Nothing
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
let (w, h) = layout.grid();
Sheet {
layout,
tiles: (0..(w * h) as u16)
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
.collect(),
}
}
fn predicate(json: &str) -> Connection {
Connection::parse(&Json::parse(json).unwrap()).unwrap()
}
fn layer(layer: u16) -> TileLayer {
TileLayer::Layer {
layer,
translucent: false,
opaque: false,
}
}
/// The lookups over blocks named `names` (a state each).
fn world(
names: &[&str],
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
) -> FusionTextures {
let none = BlockProperties::default();
let states = names.iter().map(|n| Some((*n, &none)));
FusionTextures::build(sheets, rules, states)
}
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
/// `opaque`; with the overlays it hands out.
fn pick_in(
fusion: &FusionTextures,
rule: u16,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
opaque: &[u32],
) -> (Option<Pick>, Vec<u16>) {
// A face's rule counts from 1.
let rule = NonZeroU16::new(rule + 1).unwrap();
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut overlays = Vec::new();
let pick = fusion.pick(
rule,
state,
face,
-1,
at,
|s| opaque.contains(&s),
|o| overlays.push(o.layer),
);
(pick, overlays)
}
#[test]
fn glass_pieces_join_the_same_block() {
// Glass twice (two states of one block), then stone.
let fusion = world(
&[
"minecraft:air",
"minecraft:glass",
"minecraft:glass",
"minecraft:stone",
],
vec![sheet(Layout::Pieced, 100, &[])],
vec![(0, Connection::SameBlock)],
);
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
// Alone: every quarter the lone tile.
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
// A north face's right is west: glass there (in its other state) joins the right
// quarters, the horizontal piece. Stone doesn't.
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
assert_eq!(
at(&[([-1, 0, 0], 3)]),
Some(Pick::Quarters([layer(100); 4]))
);
// A rule that doesn't exist draws as vanilla.
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
}
#[test]
fn overlays_reach_toward_visible_sand() {
let sand = predicate(
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
);
let fusion = world(
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
vec![sheet(Layout::Overlay, 200, &[7])],
vec![(0, sand)],
);
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
// over a face that draws nothing itself.
let north = [([0, 0, -1], 2)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
(Some(Pick::Nothing), vec![213])
);
// Stone over that sand hides its top: no edge.
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
(Some(Pick::Nothing), vec![])
);
// Sand all round: the ring, (4, 1).
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
.filter(|(o, _)| *o != [0, 0, 0])
.collect();
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
// Its pieces take the quad's tint index.
let mut tints = Vec::new();
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
let rule = NonZeroU16::new(1).unwrap();
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
assert_eq!(tints, [Some((0, None))]);
}
#[test]
fn in_front_and_empty_tiles() {
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
// lone tile empty.
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
let fusion = world(
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
vec![sheet(Layout::Full, 300, &[0])],
vec![(0, front)],
);
// Sand on the block east of the top face: joined right, the full template's (1, 0).
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
Some(Pick::Tile(layer(301)))
);
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
Some(Pick::Nothing)
);
}
#[test]
fn models_name_the_predicates_their_faces_draw() {
let root =
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// A pane arm over a template whose `particle` names the pane sheet but draws no face
// with it (as Fusion Connected Glass's posts do): only the faces count.
fs::write(
models.join("template.json"),
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
)
.unwrap();
fs::write(
models.join("arm.json"),
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
"connections": {"edge": {"type": "true"}},
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
)
.unwrap();
fs::write(
models.join("plain.json"),
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
)
.unwrap();
let arm = connecting_model(&root, "demo:block/arm");
let plain = connecting_model(&root, "demo:block/plain");
fs::remove_dir_all(&root).unwrap();
// `edge` has the model's predicate; `pane` none, so the texture's own.
assert_eq!(
arm,
[
(
"demo:block/edge".to_owned(),
Face::Up,
Some(Connection::Const(true))
),
("demo:block/glass".to_owned(), Face::West, None),
]
);
// Not a Fusion model: vanilla.
assert!(plain.is_empty());
}
#[test]
fn faces_of_one_sprite_join_by_their_own_keys() {
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// The wiki's example: four keys on one texture, two of them joining by `blue`.
fs::write(
models.join("tiles.json"),
r##"{"loader": "fusion:model", "type": "connecting",
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
"connections": {"blue": {"type": "match_block", "block": "ice"},
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
"up": {"texture": "#ice", "connections": "#blue"},
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
)
.unwrap();
let faces = connecting_model(&root, "demo:block/tiles");
fs::remove_dir_all(&root).unwrap();
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
assert_eq!(joins(Face::South), Some(Connection::SameState));
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
// the east.
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
}
}
File diff suppressed because it is too large. Load diff
+306 -41
View File
@@ -4,7 +4,7 @@
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -157,21 +157,24 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
} else {
(Self::indices(device, 1 << 16, flat), None)
}
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
}
fn with_usage(
device: &wgpu::Device,
@@ -212,12 +215,13 @@ impl Arena {
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
let (label, bytes) = ("Section buffer arena", capacity * self.stride);
#[cfg(test)]
let local = (!self.video_memory_full)
.then(|| try_create(device, capacity * self.stride, usage))
.then(|| try_create(device, label, bytes, usage))
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = try_create(device, label, bytes, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
@@ -229,11 +233,7 @@ impl Arena {
{
return None;
}
let buffer = try_create(
device,
capacity * self.stride,
usage | wgpu::BufferUsages::MAP_WRITE,
)?;
let buffer = try_create(device, label, bytes, usage | wgpu::BufferUsages::MAP_WRITE)?;
if !std::mem::replace(&mut self.spilled, true) {
log::warn!(
"Horizon: video memory is full; more terrain is kept in system memory \
@@ -503,12 +503,17 @@ impl Arena {
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
}
for (page, ranges) in by_page.into_iter().enumerate() {
if !ranges.is_empty() {
self.pages[page]
.as_mut()
.expect("live arena page")
.free
.give_all(ranges);
if ranges.is_empty() {
continue;
}
let page = self.pages[page].as_mut().expect("live arena page");
// Every range of the page given back (a far teleport empties the arena): one
// free range, without sorting tens of thousands into the list.
let given: u64 = ranges.iter().map(|r| r.1).sum();
if page.free_vertices() + given == page.capacity {
page.free = FreeList::new(page.capacity);
} else {
page.free.give_all(ranges);
}
}
}
@@ -545,14 +550,17 @@ impl Arena {
old.free.give(previous, capacity - previous);
true
}
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
/// the buffers of the pages it emptied, for a [`Graveyard`].
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
let mut released = Vec::new();
for page in &mut self.pages {
if let Some(p) = page
&& p.free.0.len() == 1
&& p.free.0.get(&0) == Some(&p.capacity)
if page
.as_ref()
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
&& let Some(p) = page.take()
{
*page = None;
released.push(p.buffer);
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
@@ -562,19 +570,78 @@ impl Arena {
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
}
released
}
pub fn clear(&mut self) {
self.pages.clear();
/// Drops every page; gives back their buffers, for a [`Graveyard`].
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
self.closing = None;
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
}
}
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
/// thread holds its last reference.
const GRAVE_FRAMES: usize = 4;
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
pub struct Graveyard<T> {
graves: VecDeque<Vec<T>>,
}
impl<T> Default for Graveyard<T> {
fn default() -> Self {
Self {
graves: VecDeque::new(),
}
}
}
impl<T> Graveyard<T> {
/// Released this frame.
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
if self.graves.is_empty() {
self.graves.push_back(Vec::new());
}
if let Some(grave) = self.graves.back_mut() {
grave.extend(items);
}
}
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
pub fn advance(&mut self) -> Vec<T> {
self.graves.push_back(Vec::new());
if self.graves.len() > GRAVE_FRAMES {
self.graves.pop_front().unwrap_or_default()
} else {
Vec::new()
}
}
}
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
/// unloaded chunks; should the thread not start, they drop here.
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
if items.is_empty() {
return;
}
let _ = std::thread::Builder::new()
.name("Buffer freeing".into())
.spawn(move || drop(items));
}
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
pub(super) fn try_create(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> Option<wgpu::Buffer> {
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section buffer arena"),
label: Some(label),
size,
usage,
mapped_at_creation: false,
@@ -587,6 +654,24 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
mod tests {
use super::*;
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
#[test]
fn graveyard_frees_after_its_frames() {
let mut graves = Graveyard::default();
graves.add([1, 2]);
let mut freed = Vec::new();
for frame in 0..GRAVE_FRAMES + 2 {
if frame == 1 {
graves.add([3]);
}
freed.push(graves.advance());
}
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
expected[GRAVE_FRAMES - 1] = vec![1, 2];
expected[GRAVE_FRAMES] = vec![3];
assert_eq!(freed, expected);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
device_with(wgpu::Features::empty())
}
@@ -668,6 +753,41 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
/// Released together, every range of a page leaves it one free range (a far teleport
/// empties the arena at once), while a page keeping some ranges frees only the others.
#[test]
fn ranges_released_at_once_free_exactly_their_vertices() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
let ranges: Vec<(u32, u64)> = (0..8)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// All four of the first page, the middle two of the second.
let released: Vec<(u32, u64, u64)> = ranges
.iter()
.enumerate()
.filter(|&(i, _)| i < 4 || i == 5 || i == 6)
.map(|(_, &(page, start))| (page, start, GRAIN))
.collect();
arena.release_all(released);
let free = |page: u32| -> Vec<(u64, u64)> {
let free = &arena.pages[page as usize].as_ref().unwrap().free.0;
free.iter().map(|(&start, &len)| (start, len)).collect()
};
assert_eq!(free(first), vec![(0, 4 * GRAIN)]);
assert_eq!(free(second), vec![(ranges[5].1, 2 * GRAIN)]);
// The emptied page takes a whole page's range again, without growing.
let pages = arena.pages.iter().flatten().count();
assert_eq!(arena.alloc_existing(4 * GRAIN), Some((first, 0)));
assert_eq!(arena.pages.iter().flatten().count(), pages);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
@@ -742,10 +862,11 @@ mod tests {
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
/// `translucent_at` along. GPU culling's records of the sections with solid
/// quads move with them.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{Part, SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, gpu_culling, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -756,11 +877,14 @@ mod tests {
start,
translucent_at,
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -808,6 +932,16 @@ mod tests {
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
// The sections with solid quads have records, on page 0, each with
// its cell's box.
let mut records = gpu_culling::Records::default();
for (i, s) in sections.iter_mut().enumerate() {
if s.solid > 0 {
s.gpu_id = Some(records.insert(s.record((i as i32, 0, 0), None)));
}
}
let recorded = records.live() as u32;
assert_eq!(records.page_sections(), [recorded], "split {split}");
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
@@ -816,6 +950,7 @@ mod tests {
&mut arena,
&mut encoder,
None,
Some(&mut records),
meshes,
0,
Part::Section { split },
@@ -825,6 +960,10 @@ mod tests {
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
// Its record names its solid quads where they are now, its box kept.
let record = (s.solid > 0).then(|| s.record((i as i32, 0, 0), None));
let held = s.gpu_id.and_then(|id| records.at(id));
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
@@ -843,6 +982,8 @@ mod tests {
}
}
}
// Page 0's count went to page 1 with them.
assert_eq!(records.page_sections(), [0, recorded], "split {split}");
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
@@ -852,6 +993,125 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
solid_slots: 64 * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
None,
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -953,9 +1213,9 @@ mod tests {
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -975,9 +1235,9 @@ mod tests {
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert_eq!(storage, flat, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
@@ -1039,11 +1299,14 @@ mod tests {
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1096,7 +1359,9 @@ mod tests {
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
assert!(move_off_page(
arena, encoder, None, None, held, 0, part, budget
));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
@@ -1156,7 +1421,7 @@ mod tests {
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let buffer = try_create(&device, "Stand-in page", 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
@@ -0,0 +1,574 @@
//! `horizon-ctl render-capture`: what the renderer needs to draw the current frame again
//! without the game, for `examples/scene_replay.rs` (docs/graphics.md, "Render capture and
//! replay").
//!
//! The renderer keeps no CPU copy of the meshes it uploads, so while capturing is on
//! (`Renderer::keep_capture_copies`) it keeps one of every section and far tile mesh as it is
//! handed over, at the mesher-to-renderer seam. A capture writes those of the sections and
//! tiles it holds, the frame's camera and inputs, and the settings that change GPU work. Only
//! the build that wrote a capture reads it.
//!
//! Files: `scene.json` (`Scene`), `sections.json` and `tiles.json` (one `MeshRecord` per mesh,
//! in order), `sections.bin` and `tiles.bin` (each mesh's solid then translucent
//! `TerrainVertex`es), `celestial.bin` (the sun and moon `Vertex`es).
use super::{Frame, Renderer, SectionKey};
use crate::{
Result,
environment::Sky,
fast_hash::{FxHashMap, FxHashSet},
fog::FogData,
graphics::GraphicsApi,
hud::HudBatch,
lod::TileKey,
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
};
use glam::{DVec3, Mat4, Vec3};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io::{BufReader, BufWriter, Read, Write},
path::{Path, PathBuf},
};
/// CPU copies of the meshes uploaded since capturing was turned on.
#[derive(Default)]
pub(super) struct Copies {
sections: FxHashMap<SectionKey, SectionMesh>,
tiles: FxHashMap<TileKey, SectionMesh>,
}
impl Copies {
pub(super) fn section(&mut self, key: SectionKey, mesh: &SectionMesh) {
self.sections.insert(key, copy(mesh));
}
/// Columns unloaded (`Renderer::remove_columns`).
pub(super) fn remove_columns(&mut self, columns: &[(i32, i32)]) {
if columns.is_empty() {
return;
}
let gone: FxHashSet<(i32, i32)> = columns.iter().copied().collect();
self.sections
.retain(|&(x, _, z), _| !gone.contains(&(x, z)));
}
pub(super) fn tile(&mut self, key: TileKey, mesh: &SectionMesh) {
self.tiles.insert(key, copy(mesh));
}
pub(super) fn forget_tile(&mut self, key: TileKey) {
self.tiles.remove(&key);
}
pub(super) fn clear_tiles(&mut self) {
self.tiles.clear();
}
}
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
packed: mesh.packed.clone(),
solid_tints: Vec::new(),
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh.bounds,
keep: mesh.keep,
}
}
/// A capture asked for: where to write it, and the game's settings the renderer does not hold.
pub struct Request {
pub dir: PathBuf,
pub settings: Settings,
}
/// The game's settings that change GPU work, as the game applied them to the renderer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Settings {
/// `GraphicsApi`'s variant name.
pub graphics_api: String,
pub translucent_batching: bool,
/// `textureFiltering` and `maxAnisotropyBit` (`Renderer::set_texture_filtering`).
pub texture_filtering: i32,
pub anisotropy_bit: i32,
pub mipmap_levels: u32,
/// `Renderer::set_world_post`'s arguments.
pub resolution_scale: f32,
pub fxaa: bool,
pub taa: bool,
pub upscale: f32,
/// Chunks.
pub render_distance: i32,
pub lod_distance: u32,
/// The game's prepared resources, where the replay loads the block sprites from.
pub resources: PathBuf,
/// The block sprites the meshes were made against.
pub atlas: AtlasCheck,
}
impl Settings {
pub fn graphics_api(&self) -> GraphicsApi {
GraphicsApi::ALL
.into_iter()
.find(|api| format!("{api:?}") == self.graphics_api)
.unwrap_or_default()
}
}
/// Which block sprite layers the meshes' vertices name: the replay's must be the same.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtlasCheck {
pub sprite_size: u32,
pub texture_layers: u32,
/// FNV-1a of every sprite's name and layer.
pub layers: u64,
}
impl AtlasCheck {
pub fn of(render: &crate::assets::RenderAssets) -> Self {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for (name, layer) in &render.sprite_layers {
for byte in name.bytes().chain(layer.to_le_bytes()) {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3);
}
}
Self {
sprite_size: render.sprite_size,
texture_layers: render.texture_layers,
layers: hash,
}
}
}
/// The renderer's own state that changes GPU work.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RendererState {
pub adapter: String,
pub backend: String,
/// The frame's size in pixels.
pub size: [u32; 2],
/// The column layout: lowest block y and sections.
pub min_y: i32,
pub sections: usize,
pub flat_draws: bool,
pub section_cull: bool,
pub section_sides: bool,
pub gpu_culling: bool,
pub gamma: f32,
pub fullbright: bool,
pub improved_transparency: bool,
pub animated_textures: bool,
/// A shader pack was running; the replay draws without it.
pub shader_pack: bool,
}
/// The cloud inputs of the frame (`clouds::Input`), with the clouds texture.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Clouds {
pub texture: (u32, u32, Vec<bool>),
pub uv: [f32; 2],
pub camera: [f64; 3],
pub time: i64,
pub partial_tick: f64,
pub range: i32,
pub fast: bool,
pub color: [f32; 4],
}
/// The frame's camera and the inputs of its globals, sky, fog and lightmap.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FrameInputs {
pub camera: [f64; 3],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
pub first_person: bool,
pub bob: [f32; 16],
pub spin: [f32; 16],
pub sky: Sky,
pub distance: f32,
pub detail_distance: f32,
pub depth_far: f32,
pub underwater: bool,
pub water_plane: Option<f32>,
pub cave_culling: bool,
pub sky_blocked: bool,
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
pub dark_disc: bool,
pub nether_lighting: bool,
pub glint_alpha: f32,
pub glint_ticks: f32,
pub hand_fov_degrees: f32,
pub stars: Option<([f32; 16], f32)>,
pub clouds: Option<Clouds>,
pub game_time: f32,
pub shader_game_time: f32,
pub end_portal_sprites: [[f32; 4]; 2],
pub crumbling_sprite: [f32; 4],
pub ticks: u64,
pub darkness: (f32, f32),
pub night_vision: f32,
pub block_flicker: f32,
pub boss_darken: f32,
}
/// `scene.json`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Scene {
pub settings: Settings,
pub renderer: RendererState,
pub frame: FrameInputs,
pub sections: usize,
pub tiles: usize,
/// The chunk columns far tiles leave out for full-detail sections.
pub lod_cutout: Vec<(i32, i32)>,
/// `celestial.bin`.
#[serde(skip)]
pub celestial: Vec<Vertex>,
}
/// One mesh of `sections.json` or `tiles.json`; its vertices follow the previous mesh's in
/// the `.bin` file. `key` is (x, section y, z) for a section, (x, z, level) for a tile.
#[derive(Serialize, Deserialize)]
struct MeshRecord {
key: [i32; 3],
solid: usize,
translucent: usize,
sides: Sides,
visibility: u64,
block_entities: Vec<([i32; 3], u32)>,
lights: Vec<([i32; 3], u32)>,
sprite_layers: Vec<u16>,
bounds: Option<[[f32; 3]; 2]>,
keep: bool,
}
/// Writes `name.json` and `name.bin`: (meshes, vertex bytes).
fn write_meshes<'m>(
dir: &Path,
name: &str,
meshes: impl Iterator<Item = ([i32; 3], &'m SectionMesh)>,
) -> Result<(usize, u64)> {
let mut blob = BufWriter::new(File::create(dir.join(format!("{name}.bin")))?);
let mut records = Vec::new();
let mut bytes = 0u64;
for (key, mesh) in meshes {
for part in [&mesh.solid, &mesh.translucent] {
let raw: &[u8] = bytemuck::cast_slice(part.as_slice());
blob.write_all(raw)?;
bytes += raw.len() as u64;
}
records.push(MeshRecord {
key,
solid: mesh.solid.len(),
translucent: mesh.translucent.len(),
sides: mesh.sides,
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh
.bounds
.map(|[min, max]| [min.to_array(), max.to_array()]),
keep: mesh.keep,
});
}
blob.flush()?;
let mut list = BufWriter::new(File::create(dir.join(format!("{name}.json")))?);
serde_json::to_writer(&mut list, &records)?;
list.flush()?;
Ok((records.len(), bytes))
}
/// Reads `name.json` and `name.bin`, handing each mesh to `each` as it is read.
pub fn read_meshes(
dir: &Path,
name: &str,
mut each: impl FnMut([i32; 3], SectionMesh),
) -> Result<()> {
let records: Vec<MeshRecord> = serde_json::from_reader(BufReader::new(File::open(
dir.join(format!("{name}.json")),
)?))?;
let mut blob = BufReader::new(File::open(dir.join(format!("{name}.bin")))?);
for record in records {
let mut solid = vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.solid];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
solid.as_mut_slice(),
))?;
let mut translucent =
vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.translucent];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
translucent.as_mut_slice(),
))?;
each(
record.key,
SectionMesh {
solid,
sides: record.sides,
translucent,
visibility: record.visibility,
block_entities: record.block_entities,
lights: record.lights,
sprite_layers: record.sprite_layers,
bounds: record
.bounds
.map(|[min, max]| [Vec3::from_array(min), Vec3::from_array(max)]),
keep: record.keep,
..Default::default()
},
);
}
Ok(())
}
impl Renderer {
/// Keeps CPU copies of the meshes uploaded from now on, for `render-capture`, or drops
/// them. They start empty: the caller meshes everything again for a complete capture.
pub fn keep_capture_copies(&mut self, on: bool) {
if on != self.capture_copies.is_some() {
self.capture_copies = on.then(Copies::default);
}
}
pub fn keeps_capture_copies(&self) -> bool {
self.capture_copies.is_some()
}
/// Writes the scene `frame` draws to `request.dir` (`render` calls it for `capture`).
pub(super) fn write_capture(
&self,
request: &Request,
frame: &Frame,
) -> Result<serde_json::Value> {
let copies = self
.capture_copies
.as_ref()
.ok_or("no mesh copies kept: render-capture on first")?;
let dir = &request.dir;
fs::create_dir_all(dir)?;
let (sections, section_bytes) = write_meshes(
dir,
"sections",
copies
.sections
.iter()
.map(|(&(x, y, z), mesh)| ([x, y, z], mesh)),
)?;
let standing = self.standing_lods();
let (tiles, tile_bytes) = write_meshes(
dir,
"tiles",
copies
.tiles
.iter()
.filter(|(key, _)| standing.contains(*key))
.map(|(key, mesh)| ([key.x, key.z, i32::from(key.level)], mesh)),
)?;
fs::write(
dir.join("celestial.bin"),
bytemuck::cast_slice::<Vertex, u8>(frame.celestial),
)?;
let scene = Scene {
settings: request.settings.clone(),
renderer: RendererState {
adapter: self.info.name.clone(),
backend: format!("{:?}", self.info.backend),
size: [self.config.width, self.config.height],
min_y: self.layout.min_y(),
sections: self.layout.sections(),
flat_draws: self.flat_draws(),
section_cull: self.section_cull(),
section_sides: self.section_sides(),
gpu_culling: self.gpu_culling(),
gamma: self.gamma,
fullbright: self.fullbright,
improved_transparency: self.improved_transparency,
animated_textures: self.animated_textures,
shader_pack: self.pack_post.is_some() || self.native.is_some(),
},
frame: FrameInputs {
camera: frame.camera.to_array(),
yaw: frame.yaw,
pitch: frame.pitch,
fov_degrees: frame.fov_degrees,
eye: frame.eye.to_array(),
eye_rotation: frame.eye_rotation,
first_person: frame.first_person,
bob: frame.bob.to_cols_array(),
spin: frame.spin.to_cols_array(),
sky: frame.sky,
distance: frame.distance,
detail_distance: frame.detail_distance,
depth_far: frame.depth_far,
underwater: frame.underwater,
water_plane: frame.water_plane,
cave_culling: frame.cave_culling,
sky_blocked: frame.sky_blocked,
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
dark_disc: frame.dark_disc,
nether_lighting: frame.nether_lighting,
glint_alpha: frame.glint_alpha,
glint_ticks: frame.glint_ticks,
hand_fov_degrees: frame.hand_fov_degrees,
stars: frame
.stars
.map(|(rotation, brightness)| (rotation.to_cols_array(), brightness)),
clouds: frame.clouds.as_ref().map(|c| Clouds {
texture: c.texture.clone(),
uv: c.uv,
camera: c.camera.to_array(),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
game_time: frame.game_time,
shader_game_time: frame.shader_game_time,
end_portal_sprites: frame.end_portal_sprites,
crumbling_sprite: frame.crumbling_sprite,
ticks: frame.ticks,
darkness: frame.darkness,
night_vision: frame.night_vision,
block_flicker: frame.block_flicker,
boss_darken: frame.boss_darken,
},
sections,
tiles,
lod_cutout: self.lod_cutout.iter().copied().collect(),
celestial: Vec::new(),
};
let mut out = BufWriter::new(File::create(dir.join("scene.json"))?);
serde_json::to_writer_pretty(&mut out, &scene)?;
out.flush()?;
Ok(serde_json::json!({
"ok": true,
"dir": dir.display().to_string(),
"sections": sections,
"tiles": tiles,
"vertex_bytes": section_bytes + tile_bytes,
"shader_pack_left_out": scene.renderer.shader_pack,
}))
}
}
impl Scene {
/// Reads `scene.json` and `celestial.bin` (the meshes: `read_meshes`).
pub fn read(dir: &Path) -> Result<Self> {
let mut scene: Scene =
serde_json::from_reader(BufReader::new(File::open(dir.join("scene.json"))?))?;
let bytes = fs::read(dir.join("celestial.bin"))?;
let size = std::mem::size_of::<Vertex>();
let mut celestial = vec![<Vertex as bytemuck::Zeroable>::zeroed(); bytes.len() / size];
let len = celestial.len() * size;
bytemuck::cast_slice_mut::<Vertex, u8>(celestial.as_mut_slice())
.copy_from_slice(&bytes[..len]);
scene.celestial = celestial;
Ok(scene)
}
/// The captured frame: its camera, sky, fog, clouds, sun, moon and stars, without
/// entities, particles, weather, the hand, the outline or the HUD.
pub fn frame<'a>(&'a self, hud: &'a HudBatch) -> Frame<'a> {
let f = &self.frame;
Frame {
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
pitch: f.pitch,
fov_degrees: f.fov_degrees,
eye: DVec3::from_array(f.eye),
eye_rotation: f.eye_rotation,
first_person: f.first_person,
bob: Mat4::from_cols_array(&f.bob),
spin: Mat4::from_cols_array(&f.spin),
sky: f.sky,
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: f.distance,
detail_distance: f.detail_distance,
depth_far: f.depth_far,
underwater: f.underwater,
water_plane: f.water_plane,
cave_culling: f.cave_culling,
title: false,
sky_blocked: f.sky_blocked,
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
dark_disc: f.dark_disc,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: f.glint_alpha,
glint_ticks: f.glint_ticks,
nether_lighting: f.nether_lighting,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: f.clouds.as_ref().map(|c| crate::clouds::Input {
texture: &c.texture,
uv: c.uv,
camera: DVec3::from_array(c.camera),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &self.celestial,
stars: f
.stars
.map(|(rotation, brightness)| (Mat4::from_cols_array(&rotation), brightness)),
hand: &[],
hand_fov_degrees: f.hand_fov_degrees,
screen: &[],
pack: Default::default(),
border: None,
game_time: f.game_time,
shader_game_time: f.shader_game_time,
end_portal_sprites: f.end_portal_sprites,
crumbling_sprite: f.crumbling_sprite,
ticks: f.ticks,
darkness: f.darkness,
night_vision: f.night_vision,
block_flicker: f.block_flicker,
boss_darken: f.boss_darken,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
menu_blur: None,
}
}
}
@@ -0,0 +1,486 @@
//! A resource pack's `core/rendertype_lines` shader on the block outline (`RenderTypes.LINES`,
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
/// `gl_VertexIndex % 2`, as `vs_line` draws them.
pub(crate) const GLUE: Glue = Glue {
inputs: &[
("Position", "vec3"),
("Color", "vec4"),
("Normal", "vec3"),
("LineWidth", "float"),
],
prelude: "layout(location = 0) in vec3 hz_start;\n\
layout(location = 1) in vec3 hz_end;\n\
layout(location = 2) in vec4 hz_argb;\n\
layout(location = 3) in float hz_width;",
// ARGB in a little-endian u32 reads as B, G, R, A. ShapeRenderer's normal: the direction.
setup: "Position = hz_start; if (int(gl_VertexIndex) >= 2) Position = hz_end; \
Color = hz_argb.zyxw; Normal = normalize(hz_end - hz_start); LineWidth = hz_width;",
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
pub(super) struct CoreLines {
lines: wgpu::RenderPipeline,
secondary: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl CoreLines {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
program: &CoreProgram,
) -> Option<Self> {
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = |label, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("Resource pack lines (vertex)", &program.vertex);
let fragment = module("Resource pack lines (fragment)", &program.fragment);
// Vanilla's LINES pipeline has no samplers: a pack that reads one is not used. The
// blocks' sizes are checked here too, not at the draw: a pack's own larger block fails
// in this scope.
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack lines"),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack lines uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack lines"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack lines"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
// The built-in outline's state: a strip per edge, reversed-Z depth test, translucent.
let pipeline = |label, depth_write| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(super::pack_lines::layout())],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(depth_write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let lines = pipeline("Resource pack block outline", true);
let secondary = pipeline("Resource pack block outline secondary", false);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's rendertype_lines shader is not used: {e}");
return None;
}
Some(Self {
lines,
secondary,
group,
uniform,
})
}
pub(super) fn uniform(&self) -> &wgpu::Buffer {
&self.uniform
}
/// Draw `edges` (instances of the outline buffer bound at slot 0) with the pack's shader:
/// the secondary stroke without depth writes. The caller sets its own group 0 again after.
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
secondary: bool,
edges: std::ops::Range<u32>,
) {
pass.set_pipeline(if secondary {
&self.secondary
} else {
&self.lines
});
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..4, edges);
}
}
/// The std140 blocks: `Globals` (`post_chain::Globals::bytes`), `Projection` (`jitter`, the lens
/// and the view bob `pose`, as vanilla's projection matrix carries it), `DynamicTransforms` (the
/// camera's rotation with the PERSPECTIVE `layering` scale, colour modulator white) and `Fog`
/// (`fog`: environmental start and end, render distance start and end, sky end, clouds end).
/// `ProjMat · ModelViewMat` is `vs_line`'s `view_proj` with its layering scale.
pub(super) fn uniforms(
globals: [u8; 48],
view: &super::View,
pose: Mat4,
jitter: Mat4,
layering: f32,
fog_color: [f32; 4],
fog: [f32; 6],
) -> Vec<u8> {
let projection = jitter * view.projection * pose;
let model_view = view.view * Mat4::from_scale(glam::Vec3::splat(layering));
let mut data = vec![0f32; 256];
data[64..80].copy_from_slice(&projection.to_cols_array());
let transforms = 128;
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
data[transforms + 32..transforms + 36].fill(1.0);
data[192..196].copy_from_slice(&fog_color);
data[196..202].copy_from_slice(&fog);
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
bytes[..48].copy_from_slice(&globals);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
use glam::{Vec3, Vec4};
const INCLUDES: [(&str, &str); 3] = [
(
"minecraft:shaders/include/globals.glsl",
"layout(std140) uniform Globals {\n ivec3 CameraBlockPos;\n float GlintAlpha;\n vec3 CameraOffset;\n float GameTime;\n vec2 ScreenSize;\n int MenuBlurRadius;\n int UseRgss;\n};\n",
),
(
"minecraft:shaders/include/dynamictransforms.glsl",
"layout(std140) uniform DynamicTransforms {\n mat4 ModelViewMat;\n mat4 TextureMat;\n vec4 ColorModulator;\n vec3 ModelOffset;\n};\n",
),
(
"minecraft:shaders/include/projection.glsl",
"layout(std140) uniform Projection {\n mat4 ProjMat;\n};\n",
),
];
fn include(id: &str) -> Option<String> {
INCLUDES
.iter()
.find(|(path, _)| *path == id)
.map(|(_, text)| (*text).to_owned())
}
/// 26.3's `rendertype_lines.vsh` in shape: a stroke `LineWidth` pixels wide across the
/// screen-space direction of `Normal`, its side from `gl_VertexIndex % 2`.
const LINES_VSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
#include <minecraft:globals.glsl>\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
layout(location = 2) in vec3 Normal;\nlayout(location = 3) in float LineWidth;\n\
layout(location = 2) out vec4 vertexColor;\n\
const mat4 SHRINK = mat4(0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 0.99, 0.0, 0.0, 0.0, 0.0, 1.0);\n\
void main() {\n vec4 from = ProjMat * SHRINK * ModelViewMat * vec4(Position, 1.0);\n\
vec4 to = ProjMat * SHRINK * ModelViewMat * vec4(Position + Normal, 1.0);\n\
vec2 along = normalize((to.xy / to.w - from.xy / from.w) * ScreenSize);\n\
vec2 side = vec2(-along.y, along.x) * LineWidth / ScreenSize;\n\
if (gl_VertexIndex % 2 == 0) {\n side = -side;\n }\n\
gl_Position = vec4(from.xy / from.w + side, from.z / from.w, 1.0) * from.w;\n\
vertexColor = Color;\n}\n";
/// An RGB outline pack's fragment shader in shape: the colour from `GameTime`.
const RGB_FSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
#include <minecraft:globals.glsl>\n\
layout(location = 2) in vec4 vertexColor;\nlayout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = vec4(GameTime, 1.0 - GameTime, vertexColor.b, 1.0);\n}\n";
/// ProjMat · ModelViewMat (with the vertex shader's shrink between them) is `vs_line`'s
/// view_proj on the layered, shrunk position: the pack's outline sits where the built-in one
/// does, view bob and TAA jitter included.
#[test]
fn the_pack_matrices_put_the_outline_where_vs_line_does() {
let view = super::super::View::screen(37.0, -21.0, 70.0, 16.0 / 9.0, 256.0);
let pose =
Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.01, -0.03, 0.0));
let jitter = Mat4::from_translation(Vec3::new(0.0004, -0.0002, 0.0));
let layering = 1.0 - 1.0 / 4096.0;
let bytes = uniforms([0; 48], &view, pose, jitter, layering, [0.0; 4], [0.0; 6]);
let data: Vec<f32> = bytes
.as_chunks()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
let projection = Mat4::from_cols_slice(&data[64..80]);
let model_view = Mat4::from_cols_slice(&data[128..144]);
assert_eq!(&data[160..164], &[1.0; 4], "ColorModulator");
let shrink = 1.0 - 1.0 / 256.0;
for p in [Vec3::new(1.5, -0.2, 3.0), Vec3::new(-4.0, 2.0, -0.5)] {
let pack =
projection * Mat4::from_scale(Vec3::splat(shrink)) * model_view * p.extend(1.0);
let native = jitter * view.view_proj(pose) * (p * layering * shrink).extend(1.0);
let error: Vec4 = (pack - native).abs();
assert!(
error.max_element() < 1e-5 * native.w.abs(),
"{pack} {native}"
);
}
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// One edge across the middle of a 64 by 64 target, 4 pixels wide, blue (ARGB `ff0000ff`),
/// at `GameTime` 0.25, drawn with `program`: the RGBA rows read back.
fn draw_edge(device: &wgpu::Device, queue: &wgpu::Queue, program: &CoreProgram) -> Vec<u8> {
const SIZE: u32 = 64;
let lines = CoreLines::new(device, wgpu::TextureFormat::Rgba8Unorm, program)
.expect("the pack's pipelines");
let target = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
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::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let color = target(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::COPY_SRC,
);
let depth = target(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::empty(),
);
let view = super::super::View {
view: Mat4::IDENTITY,
projection: super::super::world_projection(70.0, 1.0, 64.0),
aspect: 1.0,
tan_half_fov: 35f32.to_radians().tan(),
};
let globals = crate::post_chain::Globals {
game_time: 0.25,
..Default::default()
};
let data = uniforms(
globals.bytes([SIZE, SIZE], 0),
&view,
Mat4::IDENTITY,
Mat4::IDENTITY,
1.0,
[0.0; 4],
[0.0; 6],
);
queue.write_buffer(lines.uniform(), 0, &data);
let mut edge = [-1.0, 0.0, -2.0, 1.0, 0.0, -2.0, 0.0, 4.0f32];
edge[6] = f32::from_bits(0xff00_00ff);
let edges = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&edge),
usage: wgpu::BufferUsages::VERTEX,
},
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: 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::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_vertex_buffer(0, edges.slice(..));
lines.draw(&mut pass, false, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
read.slice(..).get_mapped_range().unwrap().to_vec()
}
/// The stroke's middle takes the pack's colour (`GameTime` red, its inverse green, the
/// vertex colour's blue); a pixel far from it stays clear.
fn assert_pack_coloured(pixels: &[u8]) {
let at = |x: usize, y: usize| &pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4];
for y in [31, 32] {
let pixel = at(32, y);
let want = [64, 191, 255, 255];
assert!(
pixel.iter().zip(want).all(|(&p, w)| p.abs_diff(w) <= 1),
"row {y}: {pixel:?}"
);
}
assert_eq!(at(32, 4), [0, 0, 0, 0]);
}
#[test]
fn a_pack_fragment_shader_colours_the_outline() {
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping");
return;
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
/// A pack's own include with a bigger block than vanilla's (an older `DynamicTransforms`)
/// is not used, and the outline draws natively, rather than failing at the draw.
#[test]
fn a_pack_block_bigger_than_vanillas_falls_back() {
let Some((device, _)) = device() else {
return;
};
let bigger = |id: &str| {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
/// Vanilla's own `rendertype_lines.vsh` (from the prepared resources) under a pack's
/// fragment shader.
#[test]
fn vanillas_lines_vertex_shader_takes_a_packs_fragment_shader() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
let Ok(vertex) = std::fs::read_to_string(
root.join("assets/minecraft/shaders/core/rendertype_lines.vsh"),
) else {
eprintln!(
"skipping resource-backed lines shader check: {}",
root.display()
);
return;
};
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
return;
};
assert_pack_coloured(&draw_edge(&device, &queue, &program));
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &vanilla).is_some());
}
}
+80 -20
View File
@@ -19,6 +19,10 @@
//! the results only sorts sections between the phases: a stale result draws
//! a hidden section in phase 1, or tests a section that is in sight.
//!
//! Far terrain tiles are tested the same way, with their meshes' own
//! bounds for a box (grown by a block too, which keeps their faces off the
//! box's), and resident slots of their own (`TILE`).
//!
//! Every solid draw is an entry of one of the two lists, so the CPU records
//! as many draws as without culling: drivers that unroll an indirect
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
@@ -39,6 +43,11 @@ use std::sync::{
};
const WORDS: u64 = 5 * 4;
/// A tested section's box, around its origin: its cell grown by a block.
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
/// count their slots apart from the sections' resident ones.
pub const TILE: u32 = 1 << 31;
/// Phase 1's sections are each tested every `RETEST` frames.
const RETEST: u32 = 8;
/// Culling runs while the last result read back hid at least this share of
@@ -63,6 +72,17 @@ const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// One test, as `cull.wgsl` reads it: visible index and phase 2 draws
/// (`place`), then its box's corners around the origin.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Test {
data: [u32; 4],
low: [f32; 4],
high: [f32; 4],
}
const TEST_BYTES: u64 = std::mem::size_of::<Test>() as u64;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
@@ -131,8 +151,10 @@ pub struct Cull {
phase2_capacity: u64,
tests: wgpu::Buffer,
tests_capacity: u64,
/// This frame's tests: visible index, first phase 2 draw, phase 2 draws.
test_data: Vec<[u32; 4]>,
/// This frame's tests: visible index, then its phase 2 draws in each
/// solid pass (`place`): the first of the first pass's, both counts (the
/// second's in the high half), the first of the second pass's; and its box.
test_data: Vec<Test>,
test_slots: Vec<u32>,
test_indices: Vec<u32>,
total: u64,
@@ -142,8 +164,10 @@ pub struct Cull {
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
/// Per resident slot: whether the last result read back saw it.
/// Per resident slot: whether the last result read back saw it; and per
/// far tile's slot (`TILE`).
seen: Vec<bool>,
seen_tiles: Vec<bool>,
stamp: u32,
/// Of the boxes the last test read back drew, how many it did not see
/// and how many it drew, and the share of solid indices hidden.
@@ -325,7 +349,7 @@ impl Cull {
),
phase2: buffer(device, "Section draws, phase 2", WORDS, PHASE2_USAGE),
phase2_capacity: 1,
tests: buffer(device, "Section tests", 16, TESTS_USAGE),
tests: buffer(device, "Section tests", TEST_BYTES, TESTS_USAGE),
tests_capacity: 1,
test_data: Vec::new(),
test_slots: Vec::new(),
@@ -347,6 +371,7 @@ impl Cull {
next: 0,
current: None,
seen: Vec::new(),
seen_tiles: Vec::new(),
stamp: 0,
hidden: ([0; 2], 1.0),
always: false,
@@ -378,11 +403,15 @@ impl Cull {
let share = indices as f32 / r.total.max(1) as f32;
self.hidden = ([hidden, results.len()], share);
for (&slot, &result) in r.slots.iter().zip(&results) {
let slot = slot as usize;
if slot >= self.seen.len() {
self.seen.resize(slot + 1, false);
let (seen, slot) = if slot & TILE != 0 {
(&mut self.seen_tiles, (slot & !TILE) as usize)
} else {
(&mut self.seen, slot as usize)
};
if slot >= seen.len() {
seen.resize(slot + 1, false);
}
self.seen[slot] = result == r.stamp;
seen[slot] = result == r.stamp;
}
}
r.buffer.unmap();
@@ -397,30 +426,61 @@ impl Cull {
!self.probe || self.stamp.is_multiple_of(PROBE)
}
/// Whether phase 1 draws the section in resident `slot`.
/// Whether phase 1 draws the section in resident `slot` (or the far tile
/// in its `TILE` slot).
pub fn first(&self, slot: u32) -> bool {
if slot & TILE != 0 {
return self
.seen_tiles
.get((slot & !TILE) as usize)
.copied()
.unwrap_or(false);
}
self.seen.get(slot as usize).copied().unwrap_or(false)
}
/// A section phase 1 draws, `index` in the visible list, with `indices`
/// solid indices: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32) {
/// solid indices and the box `bounds` around its origin: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32, bounds: [[f32; 3]; 2]) {
self.total += u64::from(indices);
if indices > 0 && (self.probe || slot.wrapping_add(self.stamp).is_multiple_of(RETEST)) {
self.push([index as u32, 0, 0, 0], slot, indices);
self.push(index, bounds, slot, indices);
}
}
/// A section left to phase 2: its `draws` from phase 2's `first`.
pub fn deferred(&mut self, index: usize, slot: u32, first: usize, draws: usize, indices: u32) {
/// A section left to phase 2, with `draws` draws there: its test, whose
/// draws `place` gives once phase 2's list is in its final order.
pub fn deferred(
&mut self,
index: usize,
slot: u32,
draws: usize,
indices: u32,
bounds: [[f32; 3]; 2],
) -> Option<u32> {
self.total += u64::from(indices);
if draws > 0 {
self.push([index as u32, first as u32, draws as u32, 0], slot, indices);
}
(draws > 0).then(|| {
self.push(index, bounds, slot, indices);
self.test_data.len() as u32 - 1
})
}
fn push(&mut self, test: [u32; 4], slot: u32, indices: u32) {
self.test_data.push(test);
/// `test`'s draws in solid pass `pass` (0 or 1): `draws` from phase 2's
/// `first`. Each pass draws its own part of the section's quads.
pub fn place(&mut self, test: u32, pass: u32, first: u32, draws: u32) {
debug_assert!(pass < 2 && draws < 1 << 16);
let t = &mut self.test_data[test as usize].data;
t[1 + 2 * pass as usize] = first;
t[2] |= draws << (16 * pass);
}
fn push(&mut self, index: usize, bounds: [[f32; 3]; 2], slot: u32, indices: u32) {
let [low, high] = bounds.map(|[x, y, z]| [x, y, z, 0.0]);
self.test_data.push(Test {
data: [index as u32, 0, 0, 0],
low,
high,
});
self.test_slots.push(slot);
self.test_indices.push(indices);
}
@@ -454,7 +514,7 @@ impl Cull {
self.tests = buffer(
device,
"Section tests",
self.tests_capacity * 16,
self.tests_capacity * TEST_BYTES,
TESTS_USAGE,
);
self.results = buffer(
Loaded 100 of 1586 files, more files were not shown because too many files have changed in this diff. Show more