mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
commit
c25f708490
1586 files changed
+343405
-22137
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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())?)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) = (¤t, 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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, |_| {}));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
¯os,
|
||||
&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(®ion, 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, ¯os, &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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¤t.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,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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| §ion.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| §ion.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2430
-377
File diff suppressed because it is too large.
Load diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user